653 lines
17 KiB
Perl
653 lines
17 KiB
Perl
# 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 parent qw(Bugzilla::Object);
|
|
|
|
use Bugzilla::Attachment;
|
|
use Bugzilla::Comment::TagWeights;
|
|
use Bugzilla::Constants;
|
|
use Bugzilla::Error;
|
|
use Bugzilla::User;
|
|
use Bugzilla::Util;
|
|
|
|
use List::Util qw(first);
|
|
use Scalar::Util qw(blessed);
|
|
|
|
###############################
|
|
#### 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
|
|
);
|
|
|
|
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,
|
|
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'],
|
|
};
|
|
|
|
#########################
|
|
# 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
|
|
if (Bugzilla->params->{'comment_taggers_group'}) {
|
|
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) . ' ' .
|
|
$dbh->sql_group_by('comment_id'));
|
|
foreach my $row (@$rows) {
|
|
$comment_map{$row->[0]}->{tags} = [ split(/,/, $row->[1]) ];
|
|
}
|
|
# Also sets the 'tags' attribute for comments which have no entry
|
|
# in the longdescs_tags table, else calling $comment->tags will
|
|
# trigger another SQL query again.
|
|
$comment_map{$_}->{tags} ||= [] foreach @comment_ids;
|
|
}
|
|
}
|
|
|
|
###############################
|
|
#### 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 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) = @_;
|
|
state $comment_taggers_group = Bugzilla->params->{'comment_taggers_group'};
|
|
return [] unless $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) = @_;
|
|
state $comment_taggers_group = Bugzilla->params->{'comment_taggers_group'};
|
|
return 0 unless $comment_taggers_group;
|
|
return $self->{collapsed} if exists $self->{collapsed};
|
|
|
|
state $collapsed_comment_tags = Bugzilla->params->{'collapsed_comment_tags'};
|
|
$self->{collapsed} = 0;
|
|
Bugzilla->request_cache->{comment_tags_collapsed}
|
|
||= [ split(/\s*,\s*/, $collapsed_comment_tags) ];
|
|
my @collapsed_tags = @{ Bugzilla->request_cache->{comment_tags_collapsed} };
|
|
foreach my $my_tag (@{ $self->tags }) {
|
|
$my_tag = lc($my_tag);
|
|
foreach my $collapsed_tag (@collapsed_tags) {
|
|
if ($my_tag eq lc($collapsed_tag)) {
|
|
$self->{collapsed} = 1;
|
|
last;
|
|
}
|
|
}
|
|
last if $self->{collapsed};
|
|
}
|
|
return $self->{collapsed};
|
|
}
|
|
|
|
sub bug {
|
|
my $self = shift;
|
|
require Bugzilla::Bug;
|
|
$self->{bug} ||= new Bugzilla::Bug($self->bug_id);
|
|
return $self->{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;
|
|
$self->{'author'}
|
|
||= new Bugzilla::User({ id => $self->{'who'}, cache => 1 });
|
|
return $self->{'author'};
|
|
}
|
|
|
|
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 ];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
##############
|
|
# 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.
|
|
|
|
# Characters above U+FFFF cannot be stored by MySQL older than 5.5.3 as they
|
|
# require the new utf8mb4 character set. Other DB servers are handling them
|
|
# without any problem. So we need to replace these characters if we use MySQL,
|
|
# else the comment is truncated.
|
|
# XXX - Once we use utf8mb4 for comments, this hack for MySQL can go away.
|
|
state $is_mysql = Bugzilla->dbh->isa('Bugzilla::DB::Mysql') ? 1 : 0;
|
|
if ($is_mysql) {
|
|
# Perl 5.13.8 and older complain about non-characters.
|
|
no warnings 'utf8';
|
|
$thetext =~ s/([\x{10000}-\x{10FFFF}])/"\x{FDD0}[" . uc(sprintf('U+%04x', ord($1))) . "]\x{FDD1}"/eg;
|
|
}
|
|
|
|
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 bug_when <= ?",
|
|
undef, $self->bug_id, $self->creation_ts);
|
|
|
|
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<Bugzilla::Object> methods. See
|
|
L<Bugzilla::Object> for more details.
|
|
|
|
=head2 Accessors
|
|
|
|
=over
|
|
|
|
=item C<bug_id>
|
|
|
|
C<int> The ID of the bug to which the comment belongs.
|
|
|
|
=item C<creation_ts>
|
|
|
|
C<string> The comment creation timestamp.
|
|
|
|
=item C<body>
|
|
|
|
C<string> The body without any special additional text.
|
|
|
|
=item C<work_time>
|
|
|
|
C<string> Time spent as related to this comment.
|
|
|
|
=item C<is_private>
|
|
|
|
C<boolean> Comment is marked as private.
|
|
|
|
=item C<already_wrapped>
|
|
|
|
If this comment is stored in the database word-wrapped, this will be C<1>.
|
|
C<0> otherwise.
|
|
|
|
=item C<author>
|
|
|
|
L<Bugzilla::User> who created the comment.
|
|
|
|
=item C<count>
|
|
|
|
C<int> The position this comment is located in the full list of comments for a bug starting from 0.
|
|
|
|
=item C<collapsed>
|
|
|
|
C<boolean> Comment should be displayed as collapsed by default.
|
|
|
|
=item C<tags>
|
|
|
|
C<array of strings> The tags attached to the comment.
|
|
|
|
=item C<add_tag>
|
|
|
|
=over
|
|
|
|
=item B<Description>
|
|
|
|
Attaches the specified tag to the comment.
|
|
|
|
=item B<Params>
|
|
|
|
=over
|
|
|
|
=item C<tag>
|
|
|
|
C<string> The tag to attach.
|
|
|
|
=back
|
|
|
|
=back
|
|
|
|
=item C<remove_tag>
|
|
|
|
=over
|
|
|
|
=item B<Description>
|
|
|
|
Detaches the specified tag from the comment.
|
|
|
|
=item B<Params>
|
|
|
|
=over
|
|
|
|
=item C<tag>
|
|
|
|
C<string> The tag to detach.
|
|
|
|
=back
|
|
|
|
=back
|
|
|
|
=item C<body_full>
|
|
|
|
=over
|
|
|
|
=item B<Description>
|
|
|
|
C<string> Body of the comment, including any special text (such as
|
|
"this bug was marked as a duplicate of...").
|
|
|
|
=item B<Params>
|
|
|
|
=over
|
|
|
|
=item C<is_bugmail>
|
|
|
|
C<boolean>. C<1> if this comment should be formatted specifically for
|
|
bugmail.
|
|
|
|
=item C<wrap>
|
|
|
|
C<boolean>. C<1> if the comment should be returned word-wrapped.
|
|
|
|
=back
|
|
|
|
=item B<Returns>
|
|
|
|
A string, the full text of the comment as it would be displayed to an end-user.
|
|
|
|
=back
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
=head1 B<Methods in need of POD>
|
|
|
|
=over
|
|
|
|
=item set_type
|
|
|
|
=item bug
|
|
|
|
=item set_extra_data
|
|
|
|
=item set_is_private
|
|
|
|
=item attachment
|
|
|
|
=item is_about_attachment
|
|
|
|
=item extra_data
|
|
|
|
=item preload
|
|
|
|
=item type
|
|
|
|
=item update
|
|
|
|
=back
|