# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Comment; use 5.10.1; use strict; use warnings; use base qw(Bugzilla::Object); use Bugzilla::Attachment; use Bugzilla::Comment::TagWeights; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Hook; use Bugzilla::User; use Bugzilla::Util; use List::Util qw(first); use Scalar::Util qw(blessed weaken isweak); use Role::Tiny::With; ############################### #### Initialization #### ############################### # Creation and updating of comments are audited in longdescs # and bugs_activity respectively instead of audit_log. use constant AUDIT_CREATES => 0; use constant AUDIT_UPDATES => 0; use constant DB_COLUMNS => qw( comment_id bug_id who bug_when work_time thetext isprivate already_wrapped type extra_data is_markdown ); use constant UPDATE_COLUMNS => qw( isprivate type extra_data ); use constant DB_TABLE => 'longdescs'; use constant ID_FIELD => 'comment_id'; # In some rare cases, two comments can have identical timestamps. If # this happens, we want to be sure that the comment added later shows up # later in the sequence. use constant LIST_ORDER => 'bug_when, comment_id'; use constant VALIDATORS => { bug_id => \&_check_bug_id, who => \&_check_who, bug_when => \&_check_bug_when, work_time => \&_check_work_time, thetext => \&_check_thetext, isprivate => \&_check_isprivate, is_markdown => \&Bugzilla::Object::check_boolean, extra_data => \&_check_extra_data, type => \&_check_type, }; use constant VALIDATOR_DEPENDENCIES => { extra_data => ['type'], bug_id => ['who'], work_time => ['who', 'bug_id'], isprivate => ['who'], }; with 'Bugzilla::Elastic::Role::ChildObject'; use constant ES_TYPE => 'comment'; use constant ES_PARENT_CLASS => 'Bugzilla::Bug'; sub ES_OBJECTS_AT_ONCE { 50 } sub ES_PROPERTIES { return { body => { type => "string", analyzer => 'bz_text_analyzer' }, is_private => { type => "boolean" }, tags => { type => "string" }, }; } sub ES_SELECT_UPDATED_SQL { my ($class, $mtime) = @_; my $sql = q{ SELECT DISTINCT comment_id FROM bugs_activity AS event JOIN fielddefs ON fieldid = fielddefs.id WHERE fielddefs.name = 'longdescs.isprivate' AND bug_when > FROM_UNIXTIME(?) UNION SELECT DISTINCT comment_id FROM longdescs_activity WHERE change_when > FROM_UNIXTIME(?) }; return ($sql, [$mtime, $mtime]); } sub es_parent_id { my ($self) = @_; return $self->ES_PARENT_CLASS->ES_TYPE . '_' . $self->bug_id, } sub es_document { my ($self) = @_; return { body => $self->body, is_private => $self->is_private, }; } ######################### # Database Manipulation # ######################### sub update { my $self = shift; my ($changes, $old_comment) = $self->SUPER::update(@_); if (exists $changes->{'thetext'} || exists $changes->{'isprivate'}) { $self->bug->_sync_fulltext( update_comments => 1); } my @old_tags = @{ $old_comment->tags }; my @new_tags = @{ $self->tags }; my ($removed_tags, $added_tags) = diff_arrays(\@old_tags, \@new_tags); if (@$removed_tags || @$added_tags) { my $dbh = Bugzilla->dbh; my $when = $dbh->selectrow_array("SELECT LOCALTIMESTAMP(0)"); my $sth_delete = $dbh->prepare( "DELETE FROM longdescs_tags WHERE comment_id = ? AND tag = ?" ); my $sth_insert = $dbh->prepare( "INSERT INTO longdescs_tags(comment_id, tag) VALUES (?, ?)" ); my $sth_activity = $dbh->prepare( "INSERT INTO longdescs_tags_activity (bug_id, comment_id, who, bug_when, added, removed) VALUES (?, ?, ?, ?, ?, ?)" ); foreach my $tag (@$removed_tags) { my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag }); if ($weighted) { if ($weighted->weight == 1) { $weighted->remove_from_db(); } else { $weighted->set_weight($weighted->weight - 1); $weighted->update(); } } trick_taint($tag); $sth_delete->execute($self->id, $tag); $sth_activity->execute( $self->bug_id, $self->id, Bugzilla->user->id, $when, '', $tag); } foreach my $tag (@$added_tags) { my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag }); if ($weighted) { $weighted->set_weight($weighted->weight + 1); $weighted->update(); } else { Bugzilla::Comment::TagWeights->create({ tag => $tag, weight => 1 }); } trick_taint($tag); $sth_insert->execute($self->id, $tag); $sth_activity->execute( $self->bug_id, $self->id, Bugzilla->user->id, $when, $tag, ''); } } return $changes; } # Speeds up displays of comment lists by loading all author objects and tags at # once for a whole list. sub preload { my ($class, $comments) = @_; # Author my %user_ids = map { $_->{who} => 1 } @$comments; my $users = Bugzilla::User->new_from_list([keys %user_ids]); my %user_map = map { $_->id => $_ } @$users; foreach my $comment (@$comments) { $comment->{author} = $user_map{$comment->{who}}; } # Tags my $dbh = Bugzilla->dbh; my @comment_ids = map { $_->id } @$comments; my %comment_map = map { $_->id => $_ } @$comments; my $rows = $dbh->selectall_arrayref( "SELECT comment_id, " . $dbh->sql_group_concat('tag', "','") . " FROM longdescs_tags WHERE " . $dbh->sql_in('comment_id', \@comment_ids) . " GROUP BY comment_id"); foreach my $row (@$rows) { $comment_map{$row->[0]}->{tags} = [ split(/,/, $row->[1]) ]; } foreach my $comment (@$comments) { $comment->{tags} //= []; } } ############################### #### Accessors ###### ############################### sub already_wrapped { return $_[0]->{'already_wrapped'}; } sub body { return $_[0]->{'thetext'}; } sub bug_id { return $_[0]->{'bug_id'}; } sub creation_ts { return $_[0]->{'bug_when'}; } sub is_private { return $_[0]->{'isprivate'}; } sub is_markdown { return $_[0]->{'is_markdown'}; } sub work_time { # Work time is returned as a string (see bug 607909) return 0 if $_[0]->{'work_time'} + 0 == 0; return $_[0]->{'work_time'}; } sub type { return $_[0]->{'type'}; } sub extra_data { return $_[0]->{'extra_data'} } sub tags { my ($self) = @_; return [] unless Bugzilla->params->{'comment_taggers_group'}; $self->{'tags'} ||= Bugzilla->dbh->selectcol_arrayref( "SELECT tag FROM longdescs_tags WHERE comment_id = ? ORDER BY tag", undef, $self->id); return $self->{'tags'}; } sub collapsed { my ($self) = @_; return $self->{collapsed} if exists $self->{collapsed}; return 0 unless Bugzilla->params->{'comment_taggers_group'}; $self->{collapsed} = 0; Bugzilla->request_cache->{comment_tags_collapsed} ||= [ split(/\s*,\s*/, lc(Bugzilla->params->{'collapsed_comment_tags'})) ]; my @collapsed_tags = @{ Bugzilla->request_cache->{comment_tags_collapsed} }; my @reason; foreach my $my_tag (map { lc } @{ $self->tags }) { foreach my $collapsed_tag (@collapsed_tags) { push @reason, $my_tag if $my_tag eq $collapsed_tag; } } if (@reason) { $self->{collapsed} = 1; $self->{collapsed_reason} = join(', ', sort @reason); } return $self->{collapsed}; } sub collapsed_reason { my ($self) = @_; return 0 unless $self->collapsed; return $self->{collapsed_reason}; } sub bug { my $self = shift; require Bugzilla::Bug; my $bug = $self->{bug} ||= new Bugzilla::Bug($self->bug_id); weaken($self->{bug}) unless isweak($self->{bug}); return $bug; } sub is_about_attachment { my ($self) = @_; return 1 if ($self->type == CMT_ATTACHMENT_CREATED or $self->type == CMT_ATTACHMENT_UPDATED); return 0; } sub attachment { my ($self) = @_; return undef if not $self->is_about_attachment; $self->{attachment} ||= new Bugzilla::Attachment({ id => $self->extra_data, cache => 1 }); return $self->{attachment}; } sub author { my $self = shift; return $self->{'author'} ||= new Bugzilla::User({ id => $self->{'who'}, cache => 1 }); } sub body_full { my ($self, $params) = @_; $params ||= {}; my $template = Bugzilla->template_inner; my $body; if ($self->type) { $template->process("bug/format_comment.txt.tmpl", { comment => $self, %$params }, \$body) || ThrowTemplateError($template->error()); $body =~ s/^X//; } else { $body = $self->body; } if ($params->{wrap} and !$self->already_wrapped) { $body = wrap_comment($body); } return $body; } ############ # Mutators # ############ sub set_is_private { $_[0]->set('isprivate', $_[1]); } sub set_type { $_[0]->set('type', $_[1]); } sub set_extra_data { $_[0]->set('extra_data', $_[1]); } sub add_tag { my ($self, $tag) = @_; $tag = $self->_check_tag($tag); my $tags = $self->tags; return if grep { lc($tag) eq lc($_) } @$tags; push @$tags, $tag; $self->{'tags'} = [ sort @$tags ]; Bugzilla::Hook::process("comment_after_add_tag", { comment => $self, tag => $tag }); } sub remove_tag { my ($self, $tag) = @_; $tag = $self->_check_tag($tag); my $tags = $self->tags; my $index = first { lc($tags->[$_]) eq lc($tag) } 0..scalar(@$tags) - 1; return unless defined $index; splice(@$tags, $index, 1); Bugzilla::Hook::process("comment_after_remove_tag", { comment => $self, tag => $tag }); } ############## # Validators # ############## sub run_create_validators { my $self = shift; my $params = $self->SUPER::run_create_validators(@_); # Sometimes this run_create_validators is called with parameters that # skip bug_id validation, so it might not exist in the resulting hash. if (defined $params->{bug_id}) { $params->{bug_id} = $params->{bug_id}->id; } return $params; } sub _check_extra_data { my ($invocant, $extra_data, undef, $params) = @_; my $type = blessed($invocant) ? $invocant->type : $params->{type}; if ($type == CMT_NORMAL) { if (defined $extra_data) { ThrowCodeError('comment_extra_data_not_allowed', { type => $type, extra_data => $extra_data }); } } else { if (!defined $extra_data) { ThrowCodeError('comment_extra_data_required', { type => $type }); } elsif ($type == CMT_ATTACHMENT_CREATED or $type == CMT_ATTACHMENT_UPDATED) { my $attachment = Bugzilla::Attachment->check({ id => $extra_data }); $extra_data = $attachment->id; } else { my $original = $extra_data; detaint_natural($extra_data) or ThrowCodeError('comment_extra_data_not_numeric', { type => $type, extra_data => $original }); } } return $extra_data; } sub _check_type { my ($invocant, $type) = @_; $type ||= CMT_NORMAL; my $original = $type; detaint_natural($type) or ThrowCodeError('comment_type_invalid', { type => $original }); return $type; } sub _check_bug_id { my ($invocant, $bug_id) = @_; ThrowCodeError('param_required', {function => 'Bugzilla::Comment->create', param => 'bug_id'}) unless $bug_id; my $bug; if (blessed $bug_id) { # We got a bug object passed in, use it $bug = $bug_id; $bug->check_is_visible; } else { # We got a bug id passed in, check it and get the bug object $bug = Bugzilla::Bug->check({ id => $bug_id }); } # Make sure the user can edit the product Bugzilla->user->can_edit_product($bug->{product_id}); # Make sure the user can comment my $privs; $bug->check_can_change_field('longdesc', 0, 1, \$privs) || ThrowUserError('illegal_change', { field => 'longdesc', privs => $privs }); return $bug; } sub _check_who { my ($invocant, $who) = @_; Bugzilla->login(LOGIN_REQUIRED); return Bugzilla->user->id; } sub _check_bug_when { my ($invocant, $when) = @_; # Make sure the timestamp is defined, default to a timestamp from the db if (!defined $when) { $when = Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); } # Make sure the timestamp parses if (!datetime_from($when)) { ThrowCodeError('invalid_timestamp', { timestamp => $when }); } return $when; } sub _check_work_time { my ($invocant, $value_in, $field, $params) = @_; # Call down to Bugzilla::Object, letting it know negative # values are ok my $time = $invocant->check_time($value_in, $field, $params, 1); my $privs; $params->{bug_id}->check_can_change_field('work_time', 0, $time, \$privs) || ThrowUserError('illegal_change', { field => 'work_time', privs => $privs }); return $time; } sub _check_thetext { my ($invocant, $thetext) = @_; ThrowCodeError('param_required',{function => 'Bugzilla::Comment->create', param => 'thetext'}) unless defined $thetext; # Remove any trailing whitespace. Leading whitespace could be # a valid part of the comment. $thetext =~ s/\s*$//s; $thetext =~ s/\r\n?/\n/g; # Get rid of \r. ThrowUserError('comment_too_long') if length($thetext) > MAX_COMMENT_LENGTH; return $thetext; } sub _check_isprivate { my ($invocant, $isprivate) = @_; if ($isprivate && !Bugzilla->user->is_insider) { ThrowUserError('user_not_insider'); } return $isprivate ? 1 : 0; } sub _check_tag { my ($invocant, $tag) = @_; length($tag) < MIN_COMMENT_TAG_LENGTH and ThrowUserError('comment_tag_too_short', { tag => $tag }); length($tag) > MAX_COMMENT_TAG_LENGTH and ThrowUserError('comment_tag_too_long', { tag => $tag }); $tag =~ /^[\w\d\._-]+$/ or ThrowUserError('comment_tag_invalid', { tag => $tag }); return $tag; } sub count { my ($self) = @_; return $self->{'count'} if defined $self->{'count'}; my $dbh = Bugzilla->dbh; ($self->{'count'}) = $dbh->selectrow_array( "SELECT COUNT(*) FROM longdescs WHERE bug_id = ? AND comment_id < ?", undef, $self->bug_id, $self->id); return $self->{'count'}; } 1; __END__ =head1 NAME Bugzilla::Comment - A Comment for a given bug =head1 SYNOPSIS use Bugzilla::Comment; my $comment = Bugzilla::Comment->new($comment_id); my $comments = Bugzilla::Comment->new_from_list($comment_ids); =head1 DESCRIPTION Bugzilla::Comment represents a comment attached to a bug. This implements all standard C methods. See L for more details. =head2 Accessors =over =item C C The ID of the bug to which the comment belongs. =item C C The comment creation timestamp. =item C C The body without any special additional text. =item C C Time spent as related to this comment. =item C C Comment is marked as private. =item C C Whether this comment needs Markdown rendering to be applied. =item C If this comment is stored in the database word-wrapped, this will be C<1>. C<0> otherwise. =item C L who created the comment. =item C C The position this comment is located in the full list of comments for a bug starting from 0. =item C C Comment should be displayed as collapsed by default. =item C C The tags attached to the comment. =item C =over =item B Attaches the specified tag to the comment. =item B =over =item C C The tag to attach. =back =back =item C =over =item B Detaches the specified tag from the comment. =item B =over =item C C The tag to detach. =back =back =item C =over =item B C Body of the comment, including any special text (such as "this bug was marked as a duplicate of..."). =item B =over =item C C. C<1> if this comment should be formatted specifically for bugmail. =item C C. C<1> if the comment should be returned word-wrapped. =back =item B A string, the full text of the comment as it would be displayed to an end-user. =back =back =cut