--- /dev/null
+.htaccess
+/lib/*
+/template/en/custom
+/docs/bugzilla.ent
+/docs/en/xml/bugzilla.ent
+/docs/en/txt
+/docs/en/html
+/docs/en/pdf
+/skins/custom
+/graphs
+/data
+/localconfig
+/index.html
+
+/skins/contrib/Dusk/IE-fixes.css
+/skins/contrib/Dusk/admin.css
+/skins/contrib/Dusk/attachment.css
+/skins/contrib/Dusk/create_attachment.css
+/skins/contrib/Dusk/dependency-tree.css
+/skins/contrib/Dusk/duplicates.css
+/skins/contrib/Dusk/editusers.css
+/skins/contrib/Dusk/enter_bug.css
+/skins/contrib/Dusk/help.css
+/skins/contrib/Dusk/panel.css
+/skins/contrib/Dusk/page.css
+/skins/contrib/Dusk/params.css
+/skins/contrib/Dusk/reports.css
+/skins/contrib/Dusk/show_bug.css
+/skins/contrib/Dusk/search_form.css
+/skins/contrib/Dusk/show_multiple.css
+/skins/contrib/Dusk/summarize-time.css
+.DS_Store
+++ /dev/null
-.htaccess
-graphs
-data
-localconfig
-index.html
-old-params.txt
-# don't allow people to retrieve non-cgi executable files or our private data
+# Don't allow people to retrieve non-cgi executable files or our private data
<FilesMatch ^(.*\.pm|.*\.pl|.*localconfig.*)$>
deny from all
</FilesMatch>
<FilesMatch ^(localconfig.js|localconfig.rdf)$>
allow from all
</FilesMatch>
+<IfModule mod_expires.c>
+<IfModule mod_headers.c>
+<IfModule mod_env.c>
+ <FilesMatch (\.js|\.css)$>
+ ExpiresActive On
+ # According to RFC 2616, "1 year in the future" means "never expire".
+ # We change the name of the file's URL whenever its modification date
+ # changes, so browsers can cache any individual JS or CSS URL forever.
+ # However, since all JS and CSS URLs involve a ? in them (for the changing
+ # name) we have to explicitly set an Expires header or browsers won't
+ # *ever* cache them.
+ ExpiresDefault "now plus 1 years"
+ Header append Cache-Control "public"
+ </FilesMatch>
+
+ # This lets Bugzilla know that we are properly sending Cache-Control
+ # and Expires headers for CSS and JS files.
+ SetEnv BZ_CACHE_CONTROL 1
+</IfModule>
+</IfModule>
+</IfModule>
# Force all connections to HTTPS for 90 days at a time.
-Header set Strict-Transport-Security "max-age=7776000"
+<IfModule mod_headers.c>
+ Header set Strict-Transport-Security "max-age=7776000"
+</IfModule>
use Bugzilla::Auth;
use Bugzilla::Auth::Persist::Cookie;
use Bugzilla::CGI;
+use Bugzilla::Extension;
use Bugzilla::DB;
use Bugzilla::Install::Localconfig qw(read_localconfig);
+use Bugzilla::Install::Requirements qw(OPTIONAL_MODULES);
+use Bugzilla::Install::Util qw(init_console);
use Bugzilla::Template;
use Bugzilla::User;
use Bugzilla::Error;
use Bugzilla::Util;
use Bugzilla::Field;
use Bugzilla::Flag;
+use Bugzilla::Token;
use File::Basename;
use File::Spec::Functions;
+use DateTime::TimeZone;
+use Date::Parse;
use Safe;
-# This creates the request cache for non-mod_perl installations.
-our $_request_cache = {};
-
#####################################################################
# Constants
#####################################################################
# Scripts that are not stopped by shutdownhtml being in effect.
-use constant SHUTDOWNHTML_EXEMPT => [
- 'editparams.cgi',
- 'checksetup.pl',
- 'recode.pl',
-];
+use constant SHUTDOWNHTML_EXEMPT => qw(
+ editparams.cgi
+ checksetup.pl
+ migrate.pl
+ recode.pl
+);
# Non-cgi scripts that should silently exit.
-use constant SHUTDOWNHTML_EXIT_SILENTLY => [
- 'whine.pl'
-];
+use constant SHUTDOWNHTML_EXIT_SILENTLY => qw(
+ whine.pl
+);
+
+# shutdownhtml pages are sent as an HTTP 503. After how many seconds
+# should search engines attempt to index the page again?
+use constant SHUTDOWNHTML_RETRY_AFTER => 3600;
#####################################################################
# Global Code
# Note that this is a raw subroutine, not a method, so $class isn't available.
sub init_page {
- (binmode STDOUT, ':utf8') if Bugzilla->params->{'utf8'};
+ if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) {
+ init_console();
+ }
+ elsif (Bugzilla->params->{'utf8'}) {
+ binmode STDOUT, ':utf8';
+ }
+
+ if (${^TAINT}) {
+ # Some environment variables are not taint safe
+ delete @::ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};
+ # Some modules throw undefined errors (notably File::Spec::Win32) if
+ # PATH is undefined.
+ $ENV{'PATH'} = '';
+ }
- # Some environment variables are not taint safe
- delete @::ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};
- # Some modules throw undefined errors (notably File::Spec::Win32) if
- # PATH is undefined.
- $ENV{'PATH'} = '';
+ # Because this function is run live from perl "use" commands of
+ # other scripts, we're skipping the rest of this function if we get here
+ # during a perl syntax check (perl -c, like we do during the
+ # 001compile.t test).
+ return if $^C;
# IIS prints out warnings to the webpage, so ignore them, or log them
# to a file if the file exists.
};
}
+ my $script = basename($0);
+
+ # Because of attachment_base, attachment.cgi handles this itself.
+ if ($script ne 'attachment.cgi') {
+ do_ssl_redirect_if_required();
+ }
+
# If Bugzilla is shut down, do not allow anything to run, just display a
# message to the user about the downtime and log out. Scripts listed in
# SHUTDOWNHTML_EXEMPT are exempt from this message.
#
- # Because this is code which is run live from perl "use" commands of other
- # scripts, we're skipping this part if we get here during a perl syntax
- # check -- runtests.pl compiles scripts without running them, so we
- # need to make sure that this check doesn't apply to 'perl -c' calls.
- #
# This code must go here. It cannot go anywhere in Bugzilla::CGI, because
# it uses Template, and that causes various dependency loops.
- if (!$^C && Bugzilla->params->{"shutdownhtml"}
- && lsearch(SHUTDOWNHTML_EXEMPT, basename($0)) == -1)
+ if (Bugzilla->params->{"shutdownhtml"}
+ && !grep { $_ eq $script } SHUTDOWNHTML_EXEMPT)
{
# Allow non-cgi scripts to exit silently (without displaying any
# message), if desired. At this point, no DBI call has been made
# yet, and no error will be returned if the DB is inaccessible.
- if (lsearch(SHUTDOWNHTML_EXIT_SILENTLY, basename($0)) > -1
- && !i_am_cgi())
+ if (!i_am_cgi()
+ && grep { $_ eq $script } SHUTDOWNHTML_EXIT_SILENTLY)
{
exit;
}
else {
$extension = 'txt';
}
- print Bugzilla->cgi->header() if i_am_cgi();
+ if (i_am_cgi()) {
+ # Set the HTTP status to 503 when Bugzilla is down to avoid pages
+ # being indexed by search engines.
+ print Bugzilla->cgi->header(-status => 503,
+ -retry_after => SHUTDOWNHTML_RETRY_AFTER);
+ }
my $t_output;
$template->process("global/message.$extension.tmpl", $vars, \$t_output)
|| ThrowTemplateError($template->error);
}
}
-init_page() if !$ENV{MOD_PERL};
-
#####################################################################
# Subroutines and Methods
#####################################################################
sub template {
my $class = shift;
- $class->request_cache->{language} = "";
$class->request_cache->{template} ||= Bugzilla::Template->create();
return $class->request_cache->{template};
}
sub template_inner {
my ($class, $lang) = @_;
- $lang = defined($lang) ? $lang : ($class->request_cache->{language} || "");
- $class->request_cache->{language} = $lang;
+ my $cache = $class->request_cache;
+ my $current_lang = $cache->{template_current_lang}->[0];
+ $lang ||= $current_lang || '';
$class->request_cache->{"template_inner_$lang"}
- ||= Bugzilla::Template->create();
+ ||= Bugzilla::Template->create(language => $lang);
return $class->request_cache->{"template_inner_$lang"};
}
+our $extension_packages;
+sub extensions {
+ my ($class) = @_;
+ my $cache = $class->request_cache;
+ if (!$cache->{extensions}) {
+ # Under mod_perl, mod_perl.pl populates $extension_packages for us.
+ if (!$extension_packages) {
+ $extension_packages = Bugzilla::Extension->load_all();
+ }
+ my @extensions;
+ foreach my $package (@$extension_packages) {
+ my $extension = $package->new();
+ if ($extension->enabled) {
+ push(@extensions, $extension);
+ }
+ }
+ $cache->{extensions} = \@extensions;
+ }
+ return $cache->{extensions};
+}
+
+sub feature {
+ my ($class, $feature) = @_;
+ my $cache = $class->request_cache;
+ return $cache->{feature}->{$feature}
+ if exists $cache->{feature}->{$feature};
+
+ my $feature_map = $cache->{feature_map};
+ if (!$feature_map) {
+ foreach my $package (@{ OPTIONAL_MODULES() }) {
+ foreach my $f (@{ $package->{feature} }) {
+ $feature_map->{$f} ||= [];
+ push(@{ $feature_map->{$f} }, $package->{module});
+ }
+ }
+ $cache->{feature_map} = $feature_map;
+ }
+
+ if (!$feature_map->{$feature}) {
+ ThrowCodeError('invalid_feature', { feature => $feature });
+ }
+
+ my $success = 1;
+ foreach my $module (@{ $feature_map->{$feature} }) {
+ # We can't use a string eval and "use" here (it kills Template-Toolkit,
+ # see https://rt.cpan.org/Public/Bug/Display.html?id=47929), so we have
+ # to do a block eval.
+ $module =~ s{::}{/}g;
+ $module .= ".pm";
+ eval { require $module; 1; } or $success = 0;
+ }
+ $cache->{feature}->{$feature} = $success;
+ return $success;
+}
+
sub cgi {
my $class = shift;
$class->request_cache->{cgi} ||= new Bugzilla::CGI();
return $class->request_cache->{cgi};
}
+sub input_params {
+ my ($class, $params) = @_;
+ my $cache = $class->request_cache;
+ # This is how the WebService and other places set input_params.
+ if (defined $params) {
+ $cache->{input_params} = $params;
+ }
+ return $cache->{input_params} if defined $cache->{input_params};
+
+ # Making this scalar makes it a tied hash to the internals of $cgi,
+ # so if a variable is changed, then it actually changes the $cgi object
+ # as well.
+ $cache->{input_params} = $class->cgi->Vars;
+ return $cache->{input_params};
+}
+
sub localconfig {
my $class = shift;
$class->request_cache->{localconfig} ||= read_localconfig();
# NOTE: If you want to log the start of an sudo session, do it here.
}
+sub page_requires_login {
+ return $_[0]->request_cache->{page_requires_login};
+}
+
sub login {
my ($class, $type) = @_;
my $authorizer = new Bugzilla::Auth();
$type = LOGIN_REQUIRED if $class->cgi->param('GoAheadAndLogIn');
+
if (!defined $type || $type == LOGIN_NORMAL) {
$type = $class->params->{'requirelogin'} ? LOGIN_REQUIRED : LOGIN_NORMAL;
}
+
+ # Allow templates to know that we're in a page that always requires
+ # login.
+ if ($type == LOGIN_REQUIRED) {
+ $class->request_cache->{page_requires_login} = 1;
+ }
+
my $authenticated_user = $authorizer->login($type);
# At this point, we now know if a real person is logged in.
# 3: There must be a valid value in the 'sudo' cookie
# 4: A Bugzilla::User object must exist for the given cookie value
# 5: That user must NOT be in the 'bz_sudo_protect' group
- my $sudo_cookie = $class->cgi->cookie('sudo');
- detaint_natural($sudo_cookie) if defined($sudo_cookie);
- my $sudo_target;
- $sudo_target = new Bugzilla::User($sudo_cookie) if defined($sudo_cookie);
- if (defined($authenticated_user) &&
- $authenticated_user->in_group('bz_sudoers') &&
- defined($sudo_cookie) &&
- defined($sudo_target) &&
- !($sudo_target->in_group('bz_sudo_protect'))
- )
- {
- $class->set_user($sudo_target);
- $class->request_cache->{sudoer} = $authenticated_user;
- # And make sure that both users have the same Auth object,
- # since we never call Auth::login for the sudo target.
- $sudo_target->set_authorizer($authenticated_user->authorizer);
+ my $token = $class->cgi->cookie('sudo');
+ if (defined $authenticated_user && $token) {
+ my ($user_id, $date, $sudo_target_id) = Bugzilla::Token::GetTokenData($token);
+ if (!$user_id
+ || $user_id != $authenticated_user->id
+ || !detaint_natural($sudo_target_id)
+ || (time() - str2time($date) > MAX_SUDO_TOKEN_AGE))
+ {
+ $class->cgi->remove_cookie('sudo');
+ ThrowUserError('sudo_invalid_cookie');
+ }
+
+ my $sudo_target = new Bugzilla::User($sudo_target_id);
+ if ($authenticated_user->in_group('bz_sudoers')
+ && defined $sudo_target
+ && !$sudo_target->in_group('bz_sudo_protect'))
+ {
+ $class->set_user($sudo_target);
+ $class->request_cache->{sudoer} = $authenticated_user;
+ # And make sure that both users have the same Auth object,
+ # since we never call Auth::login for the sudo target.
+ $sudo_target->set_authorizer($authenticated_user->authorizer);
- # NOTE: If you want to do any special logging, do it here.
+ # NOTE: If you want to do any special logging, do it here.
+ }
+ else {
+ delete_token($token);
+ $class->cgi->remove_cookie('sudo');
+ ThrowUserError('sudo_illegal_action', { sudoer => $authenticated_user,
+ target_user => $sudo_target });
+ }
}
else {
$class->set_user($authenticated_user);
}
- # We run after the login has completed since
- # some of the checks in ssl_require_redirect
- # look for Bugzilla->user->id to determine
- # if redirection is required.
- if (i_am_cgi() && ssl_require_redirect()) {
- $class->cgi->require_https($class->params->{'sslbase'});
- }
-
return $class->user;
}
# there. Don't rely on it: use Bugzilla->user->login instead!
}
+sub job_queue {
+ my $class = shift;
+ require Bugzilla::JobQueue;
+ $class->request_cache->{job_queue} ||= Bugzilla::JobQueue->new();
+ return $class->request_cache->{job_queue};
+}
+
sub dbh {
my $class = shift;
# If we're not connected, then we must want the main db
- $class->request_cache->{dbh} ||= $class->request_cache->{dbh_main}
- = Bugzilla::DB::connect_main();
+ $class->request_cache->{dbh} ||= $class->dbh_main;
return $class->request_cache->{dbh};
}
+sub dbh_main {
+ my $class = shift;
+ $class->request_cache->{dbh_main} ||= Bugzilla::DB::connect_main();
+ return $class->request_cache->{dbh_main};
+}
+
sub languages {
my $class = shift;
- return $class->request_cache->{languages}
- if $class->request_cache->{languages};
-
- my @files = glob(catdir(bz_locations->{'templatedir'}, '*'));
- my @languages;
- foreach my $dir_entry (@files) {
- # It's a language directory only if it contains "default" or
- # "custom". This auto-excludes CVS directories as well.
- next unless (-d catdir($dir_entry, 'default')
- || -d catdir($dir_entry, 'custom'));
- $dir_entry = basename($dir_entry);
- # Check for language tag format conforming to RFC 1766.
- next unless $dir_entry =~ /^[a-zA-Z]{1,8}(-[a-zA-Z]{1,8})?$/;
- push(@languages, $dir_entry);
- }
- return $class->request_cache->{languages} = \@languages;
+ return Bugzilla::Install::Util::supported_languages();
}
sub error_mode {
$class->request_cache->{error_mode} = $newval;
}
return $class->request_cache->{error_mode}
- || Bugzilla::Constants::ERROR_MODE_WEBPAGE;
+ || (i_am_cgi() ? ERROR_MODE_WEBPAGE : ERROR_MODE_DIE);
+}
+
+# This is used only by Bugzilla::Error to throw errors.
+sub _json_server {
+ my ($class, $newval) = @_;
+ if (defined $newval) {
+ $class->request_cache->{_json_server} = $newval;
+ }
+ return $class->request_cache->{_json_server};
}
sub usage_mode {
elsif ($newval == USAGE_MODE_CMDLINE) {
$class->error_mode(ERROR_MODE_DIE);
}
- elsif ($newval == USAGE_MODE_WEBSERVICE) {
+ elsif ($newval == USAGE_MODE_XMLRPC) {
$class->error_mode(ERROR_MODE_DIE_SOAP_FAULT);
}
+ elsif ($newval == USAGE_MODE_JSON) {
+ $class->error_mode(ERROR_MODE_JSON_RPC);
+ }
elsif ($newval == USAGE_MODE_EMAIL) {
$class->error_mode(ERROR_MODE_DIE);
}
+ elsif ($newval == USAGE_MODE_TEST) {
+ $class->error_mode(ERROR_MODE_TEST);
+ }
else {
ThrowCodeError('usage_mode_invalid',
{'invalid_usage_mode', $newval});
$class->request_cache->{usage_mode} = $newval;
}
return $class->request_cache->{usage_mode}
- || Bugzilla::Constants::USAGE_MODE_BROWSER;
+ || (i_am_cgi()? USAGE_MODE_BROWSER : USAGE_MODE_CMDLINE);
}
sub installation_mode {
if ($class->params->{'shadowdb'}) {
$class->request_cache->{dbh_shadow} = Bugzilla::DB::connect_shadow();
} else {
- $class->request_cache->{dbh_shadow} = request_cache()->{dbh_main};
+ $class->request_cache->{dbh_shadow} = $class->dbh_main;
}
}
sub switch_to_main_db {
my $class = shift;
- $class->request_cache->{dbh} = $class->request_cache->{dbh_main};
- # We have to return $class->dbh instead of {dbh} as
- # {dbh_main} may be undefined if no connection to the main DB
- # has been established yet.
- return $class->dbh;
+ $class->request_cache->{dbh} = $class->dbh_main;
+ return $class->dbh_main;
}
-sub get_fields {
- my $class = shift;
- my $criteria = shift;
- # This function may be called during installation, and Field::match
- # may fail at that time. so we want to return an empty list in that
- # case.
- my $fields = eval { Bugzilla::Field->match($criteria) } || [];
- return @$fields;
+sub fields {
+ my ($class, $criteria) = @_;
+ $criteria ||= {};
+ my $cache = $class->request_cache;
+
+ # We create an advanced cache for fields by type, so that we
+ # can avoid going back to the database for every fields() call.
+ # (And most of our fields() calls are for getting fields by type.)
+ #
+ # We also cache fields by name, because calling $field->name a few
+ # million times can be slow in calling code, but if we just do it
+ # once here, that makes things a lot faster for callers.
+ if (!defined $cache->{fields}) {
+ my @all_fields = Bugzilla::Field->get_all;
+ my (%by_name, %by_type);
+ foreach my $field (@all_fields) {
+ my $name = $field->name;
+ $by_type{$field->type}->{$name} = $field;
+ $by_name{$name} = $field;
+ }
+ $cache->{fields} = { by_type => \%by_type, by_name => \%by_name };
+ }
+
+ my $fields = $cache->{fields};
+ my %requested;
+ if (my $types = delete $criteria->{type}) {
+ $types = ref($types) ? $types : [$types];
+ %requested = map { %{ $fields->{by_type}->{$_} || {} } } @$types;
+ }
+ else {
+ %requested = %{ $fields->{by_name} };
+ }
+
+ my $do_by_name = delete $criteria->{by_name};
+
+ # Filtering before returning the fields based on
+ # the criterias.
+ foreach my $filter (keys %$criteria) {
+ foreach my $field (keys %requested) {
+ if ($requested{$field}->$filter != $criteria->{$filter}) {
+ delete $requested{$field};
+ }
+ }
+ }
+
+ return $do_by_name ? \%requested : [values %requested];
}
sub active_custom_fields {
my $class = shift;
if (!defined $class->request_cache->{has_flags}) {
- $class->request_cache->{has_flags} = Bugzilla::Flag::has_flags();
+ $class->request_cache->{has_flags} = Bugzilla::Flag->any_exist;
}
return $class->request_cache->{has_flags};
}
-sub hook_args {
- my ($class, $args) = @_;
- $class->request_cache->{hook_args} = $args if $args;
- return $class->request_cache->{hook_args};
+sub local_timezone {
+ my $class = shift;
+
+ if (!defined $class->request_cache->{local_timezone}) {
+ $class->request_cache->{local_timezone} =
+ DateTime::TimeZone->new(name => 'local');
+ }
+ return $class->request_cache->{local_timezone};
}
+# This creates the request cache for non-mod_perl installations.
+# This is identical to Install::Util::_cache so that things loaded
+# into Install::Util::_cache during installation can be read out
+# of request_cache later in installation.
+our $_request_cache = $Bugzilla::Install::Util::_cache;
+
sub request_cache {
if ($ENV{MOD_PERL}) {
require Apache2::RequestUtil;
- return Apache2::RequestUtil->request->pnotes();
+ # Sometimes (for example, during mod_perl.pl), the request
+ # object isn't available, and we should use $_request_cache instead.
+ my $request = eval { Apache2::RequestUtil->request };
+ return $_request_cache if !$request;
+ return $request->pnotes();
}
return $_request_cache;
}
$dbh->disconnect;
}
undef $_request_cache;
+
+ # These are both set by CGI.pm but need to be undone so that
+ # Apache can actually shut down its children if it needs to.
+ foreach my $signal (qw(TERM PIPE)) {
+ $SIG{$signal} = 'DEFAULT' if $SIG{$signal} && $SIG{$signal} eq 'IGNORE';
+ }
}
sub END {
_cleanup() unless $ENV{MOD_PERL};
}
+init_page() if !$ENV{MOD_PERL};
+
1;
__END__
general. Not all Bugzilla actions are cgi requests. Its useful as a convenience
method for those scripts/templates which are only use via CGI, though.
+=item C<input_params>
+
+When running under the WebService, this is a hashref containing the arguments
+passed to the WebService method that was called. When running in a normal
+script, this is a hashref containing the contents of the CGI parameters.
+
+Modifying this hashref will modify the CGI parameters or the WebService
+arguments (depending on what C<input_params> currently represents).
+
+This should be used instead of L</cgi> in situations where your code
+could be being called by either a normal CGI script or a WebService method,
+such as during a code hook.
+
+B<Note:> When C<input_params> represents the CGI parameters, any
+parameter specified more than once (like C<foo=bar&foo=baz>) will appear
+as an arrayref in the hash, but any value specified only once will appear
+as a scalar. This means that even if a value I<can> appear multiple times,
+if it only I<does> appear once, then it will be a scalar in C<input_params>,
+not an arrayref.
+
=item C<user>
C<undef> if there is no currently logged in user or if the login code has not
no logged in user. See L<Bugzilla::Auth|Bugzilla::Auth>, and
L<Bugzilla::User|Bugzilla::User>.
+=item C<page_requires_login>
+
+If the current page always requires the user to log in (for example,
+C<enter_bug.cgi> or any page called with C<?GoAheadAndLogIn=1>) then
+this will return something true. Otherwise it will return false. (This is
+set when you call L</login>.)
+
=item C<logout($option)>
Logs out the current user, which involves invalidating user sessions and
effect of logging out a user for the current request only; cookies and
database sessions are left intact.
+=item C<fields>
+
+This is the standard way to get arrays or hashes of L<Bugzilla::Field>
+objects when you need them. It takes the following named arguments
+in a hashref:
+
+=over
+
+=item C<by_name>
+
+If false (or not specified), this method will return an arrayref of
+the requested fields. The order of the returned fields is random.
+
+If true, this method will return a hashref of fields, where the keys
+are field names and the valules are L<Bugzilla::Field> objects.
+
+=item C<type>
+
+Either a single C<FIELD_TYPE_*> constant or an arrayref of them. If specified,
+the returned fields will be limited to the types in the list. If you don't
+specify this argument, all fields will be returned.
+
+=back
+
=item C<error_mode>
Call either C<Bugzilla->error_mode(Bugzilla::Constants::ERROR_MODE_DIE)>
=item C<usage_mode>
Call either C<Bugzilla->usage_mode(Bugzilla::Constants::USAGE_MODE_CMDLINE)>
-or C<Bugzilla->usage_mode(Bugzilla::Constants::USAGE_MODE_WEBSERVICE)> near the
+or C<Bugzilla->usage_mode(Bugzilla::Constants::USAGE_MODE_XMLRPC)> near the
beginning of your script to change this flag's default of
C<Bugzilla::Constants::USAGE_MODE_BROWSER> and to indicate that Bugzilla is
being called in a non-interactive manner.
+
This influences error handling because on usage mode changes, C<usage_mode>
calls C<Bugzilla->error_mode> to set an error mode which makes sense for the
usage mode.
The current database handle. See L<DBI>.
+=item C<dbh_main>
+
+The main database handle. See L<DBI>.
+
=item C<languages>
Currently installed languages.
does not exist, then we return an empty hashref. If C<data/params>
is unreadable or is not valid perl, we C<die>.
-=item C<hook_args>
+=item C<local_timezone>
+
+Returns the local timezone of the Bugzilla installation,
+as a DateTime::TimeZone object. This detection is very time
+consuming, so we cache this information for future references.
+
+=item C<job_queue>
+
+Returns a L<Bugzilla::JobQueue> that you can use for queueing jobs.
+Will throw an error if job queueing is not correctly configured on
+this Bugzilla installation.
+
+=item C<feature>
-If you are running inside a code hook (see L<Bugzilla::Hook>) this
-is how you get the arguments passed to the hook.
+Tells you whether or not a specific feature is enabled. For names
+of features, see C<OPTIONAL_MODULES> in C<Bugzilla::Install::Requirements>.
=back
=head1 NAME
-Bugzilla::Attachment - a file related to a bug that a user has uploaded
- to the Bugzilla server
+Bugzilla::Attachment - Bugzilla attachment class.
=head1 SYNOPSIS
use Bugzilla::Attachment;
# Get the attachment with the given ID.
- my $attachment = Bugzilla::Attachment->get($attach_id);
+ my $attachment = new Bugzilla::Attachment($attach_id);
# Get the attachments with the given IDs.
- my $attachments = Bugzilla::Attachment->get_list($attach_ids);
+ my $attachments = Bugzilla::Attachment->new_from_list($attach_ids);
=head1 DESCRIPTION
-This module defines attachment objects, which represent files related to bugs
-that users upload to the Bugzilla server.
+Attachment.pm represents an attachment object. It is an implementation
+of L<Bugzilla::Object>, and thus provides all methods that
+L<Bugzilla::Object> provides.
+
+The methods that are specific to C<Bugzilla::Attachment> are listed
+below.
=cut
use Bugzilla::User;
use Bugzilla::Util;
use Bugzilla::Field;
+use Bugzilla::Hook;
-sub get {
- my $invocant = shift;
- my $id = shift;
+use File::Copy;
+use List::Util qw(max);
- my $attachments = _retrieve([$id]);
- my $self = $attachments->[0];
- bless($self, ref($invocant) || $invocant) if $self;
+use base qw(Bugzilla::Object);
- return $self;
-}
+###############################
+#### Initialization ####
+###############################
-sub get_list {
- my $invocant = shift;
- my $ids = shift;
+use constant DB_TABLE => 'attachments';
+use constant ID_FIELD => 'attach_id';
+use constant LIST_ORDER => ID_FIELD;
+# Attachments are tracked in bugs_activity.
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
- my $attachments = _retrieve($ids);
- foreach my $attachment (@$attachments) {
- bless($attachment, ref($invocant) || $invocant);
- }
+sub DB_COLUMNS {
+ my $dbh = Bugzilla->dbh;
- return $attachments;
+ return qw(
+ attach_id
+ bug_id
+ description
+ filename
+ isobsolete
+ ispatch
+ isprivate
+ mimetype
+ modification_time
+ submitter_id),
+ $dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i') . ' AS creation_ts';
}
-sub _retrieve {
- my ($ids) = @_;
-
- return [] if scalar(@$ids) == 0;
-
- my @columns = (
- 'attachments.attach_id AS id',
- 'attachments.bug_id AS bug_id',
- 'attachments.description AS description',
- 'attachments.mimetype AS contenttype',
- 'attachments.submitter_id AS attacher_id',
- Bugzilla->dbh->sql_date_format('attachments.creation_ts',
- '%Y.%m.%d %H:%i') . " AS attached",
- 'attachments.modification_time',
- 'attachments.filename AS filename',
- 'attachments.ispatch AS ispatch',
- 'attachments.isurl AS isurl',
- 'attachments.isobsolete AS isobsolete',
- 'attachments.isprivate AS isprivate'
- );
- my $columns = join(", ", @columns);
- my $dbh = Bugzilla->dbh;
- my $records = $dbh->selectall_arrayref(
- "SELECT $columns
- FROM attachments
- WHERE "
- . Bugzilla->dbh->sql_in('attach_id', $ids)
- . " ORDER BY attach_id",
- { Slice => {} });
- return $records;
-}
+use constant REQUIRED_FIELD_MAP => {
+ bug_id => 'bug',
+};
+use constant EXTRA_REQUIRED_FIELDS => qw(data);
+
+use constant UPDATE_COLUMNS => qw(
+ description
+ filename
+ isobsolete
+ ispatch
+ isprivate
+ mimetype
+);
+
+use constant VALIDATORS => {
+ bug => \&_check_bug,
+ description => \&_check_description,
+ filename => \&_check_filename,
+ ispatch => \&Bugzilla::Object::check_boolean,
+ isprivate => \&_check_is_private,
+ mimetype => \&_check_content_type,
+};
+
+use constant VALIDATOR_DEPENDENCIES => {
+ mimetype => ['ispatch'],
+};
+
+use constant UPDATE_VALIDATORS => {
+ isobsolete => \&Bugzilla::Object::check_boolean,
+};
+
+###############################
+#### Accessors ######
+###############################
=pod
=over
-=item C<id>
+=item C<bug_id>
-the unique identifier for the attachment
+the ID of the bug to which the attachment is attached
=back
=cut
-sub id {
+sub bug_id {
my $self = shift;
- return $self->{id};
+ return $self->{bug_id};
}
=over
-=item C<bug_id>
+=item C<bug>
-the ID of the bug to which the attachment is attached
+the bug object to which the attachment is attached
=back
=cut
-# XXX Once Bug.pm slims down sufficiently this should become a reference
-# to a bug object.
-sub bug_id {
+sub bug {
my $self = shift;
- return $self->{bug_id};
+
+ require Bugzilla::Bug;
+ $self->{bug} ||= Bugzilla::Bug->new($self->bug_id);
+ return $self->{bug};
}
=over
sub contenttype {
my $self = shift;
- return $self->{contenttype};
+ return $self->{mimetype};
}
=over
sub attacher {
my $self = shift;
return $self->{attacher} if exists $self->{attacher};
- $self->{attacher} = new Bugzilla::User($self->{attacher_id});
+ $self->{attacher} = new Bugzilla::User($self->{submitter_id});
return $self->{attacher};
}
sub attached {
my $self = shift;
- return $self->{attached};
+ return $self->{creation_ts};
}
=over
=over
-=item C<isurl>
-
-whether or not the attachment is a URL
-
-=back
-
-=cut
-
-sub isurl {
- my $self = shift;
- return $self->{isurl};
-}
-
-=over
-
=item C<isobsolete>
whether or not the attachment is obsolete
FROM attach_data
WHERE id = ?",
undef,
- $self->{id});
+ $self->id);
# If there's no attachment data in the database, the attachment is stored
# in a local file, so retrieve it from there.
Bugzilla->dbh->selectrow_array("SELECT LENGTH(thedata)
FROM attach_data
WHERE id = ?",
- undef, $self->{id}) || 0;
+ undef, $self->id) || 0;
# If there's no attachment data in the database, either the attachment
# is stored in a local file, and so retrieve its size from the file,
return $self->{datasize};
}
+sub _get_local_filename {
+ my $self = shift;
+ my $hash = ($self->id % 100) + 100;
+ $hash =~ s/.*(\d\d)$/group.$1/;
+ return bz_locations()->{'attachdir'} . "/$hash/attachment." . $self->id;
+}
+
=over
=item C<flags>
sub flags {
my $self = shift;
- return $self->{flags} if exists $self->{flags};
- $self->{flags} = Bugzilla::Flag->match({ 'attach_id' => $self->id });
+ # Don't cache it as it must be in sync with ->flag_types.
+ $self->{flags} = [map { @{$_->{flags}} } @{$self->flag_types}];
return $self->{flags};
}
-# Instance methods; no POD documentation here yet because the only ones so far
-# are private.
+=over
+
+=item C<flag_types>
-sub _get_local_filename {
+Return all flag types available for this attachment as well as flags
+already set, grouped by flag type.
+
+=back
+
+=cut
+
+sub flag_types {
my $self = shift;
- my $hash = ($self->id % 100) + 100;
- $hash =~ s/.*(\d\d)$/group.$1/;
- return bz_locations()->{'attachdir'} . "/$hash/attachment." . $self->id;
+ return $self->{flag_types} if exists $self->{flag_types};
+
+ my $vars = { target_type => 'attachment',
+ product_id => $self->bug->product_id,
+ component_id => $self->bug->component_id,
+ attach_id => $self->id };
+
+ $self->{flag_types} = Bugzilla::Flag->_flag_types($vars);
+ return $self->{flag_types};
}
-sub _validate_filename {
- my ($throw_error) = @_;
- my $cgi = Bugzilla->cgi;
- defined $cgi->upload('data')
- || ($throw_error ? ThrowUserError("file_not_specified") : return 0);
+###############################
+#### Validators ######
+###############################
+
+sub set_content_type { $_[0]->set('mimetype', $_[1]); }
+sub set_description { $_[0]->set('description', $_[1]); }
+sub set_filename { $_[0]->set('filename', $_[1]); }
+sub set_is_patch { $_[0]->set('ispatch', $_[1]); }
+sub set_is_private { $_[0]->set('isprivate', $_[1]); }
+
+sub set_is_obsolete {
+ my ($self, $obsolete) = @_;
+
+ my $old = $self->isobsolete;
+ $self->set('isobsolete', $obsolete);
+ my $new = $self->isobsolete;
+
+ # If the attachment is being marked as obsolete, cancel pending requests.
+ if ($new && $old != $new) {
+ my @requests = grep { $_->status eq '?' } @{$self->flags};
+ return unless scalar @requests;
+
+ my %flag_ids = map { $_->id => 1 } @requests;
+ foreach my $flagtype (@{$self->flag_types}) {
+ @{$flagtype->{flags}} = grep { !$flag_ids{$_->id} } @{$flagtype->{flags}};
+ }
+ }
+}
- my $filename = $cgi->upload('data');
+sub set_flags {
+ my ($self, $flags, $new_flags) = @_;
+
+ Bugzilla::Flag->set_flag($self, $_) foreach (@$flags, @$new_flags);
+}
+
+sub _check_bug {
+ my ($invocant, $bug) = @_;
+ my $user = Bugzilla->user;
+
+ $bug = ref $invocant ? $invocant->bug : $bug;
+
+ $bug || ThrowCodeError('param_required',
+ { function => "$invocant->create", param => 'bug' });
+
+ ($user->can_see_bug($bug->id) && $user->can_edit_product($bug->product_id))
+ || ThrowUserError("illegal_attachment_edit_bug", { bug_id => $bug->id });
+
+ return $bug;
+}
+
+sub _check_content_type {
+ my ($invocant, $content_type, undef, $params) = @_;
+
+ my $is_patch = ref($invocant) ? $invocant->ispatch : $params->{ispatch};
+ $content_type = 'text/plain' if $is_patch;
+ $content_type = clean_text($content_type);
+ # The subsets below cover all existing MIME types and charsets registered by IANA.
+ # (MIME type: RFC 2045 section 5.1; charset: RFC 2278 section 3.3)
+ my $legal_types = join('|', LEGAL_CONTENT_TYPES);
+ if (!$content_type
+ || $content_type !~ /^($legal_types)\/[a-z0-9_\-\+\.]+(;\s*charset=[a-z0-9_\-\+]+)?$/i)
+ {
+ ThrowUserError("invalid_content_type", { contenttype => $content_type });
+ }
+ trick_taint($content_type);
+
+ return $content_type;
+}
+
+sub _check_data {
+ my ($invocant, $params) = @_;
+
+ my $data = $params->{data};
+ $params->{filesize} = ref $data ? -s $data : length($data);
+
+ Bugzilla::Hook::process('attachment_process_data', { data => \$data,
+ attributes => $params });
+
+ $params->{filesize} || ThrowUserError('zero_length_file');
+ # Make sure the attachment does not exceed the maximum permitted size.
+ my $max_size = max(Bugzilla->params->{'maxlocalattachment'} * 1048576,
+ Bugzilla->params->{'maxattachmentsize'} * 1024);
+
+ if ($params->{filesize} > $max_size) {
+ my $vars = { filesize => sprintf("%.0f", $params->{filesize}/1024) };
+ ThrowUserError('file_too_large', $vars);
+ }
+ return $data;
+}
+
+sub _check_description {
+ my ($invocant, $description) = @_;
+
+ $description = trim($description);
+ $description || ThrowUserError('missing_attachment_description');
+ return $description;
+}
+
+sub _check_filename {
+ my ($invocant, $filename) = @_;
+
+ $filename = clean_text($filename);
+ if (!$filename) {
+ if (ref $invocant) {
+ ThrowUserError('filename_not_specified');
+ }
+ else {
+ ThrowUserError('file_not_specified');
+ }
+ }
# Remove path info (if any) from the file name. The browser should do this
# for us, but some are buggy. This may not work on Mac file names and could
# Truncate the filename to 100 characters, counting from the end of the
# string to make sure we keep the filename extension.
$filename = substr($filename, -100, 100);
+ trick_taint($filename);
return $filename;
}
-sub _validate_data {
- my ($throw_error, $hr_vars) = @_;
- my $cgi = Bugzilla->cgi;
- my $maxsize = $cgi->param('ispatch') ? Bugzilla->params->{'maxpatchsize'}
- : Bugzilla->params->{'maxattachmentsize'};
- $maxsize *= 1024; # Convert from K
- my $fh;
- # Skip uploading into a local variable if the user wants to upload huge
- # attachments into local files.
- if (!$cgi->param('bigfile')) {
- $fh = $cgi->upload('data');
- }
- my $data;
-
- # We could get away with reading only as much as required, except that then
- # we wouldn't have a size to print to the error handler below.
- if (!$cgi->param('bigfile')) {
- # enable 'slurp' mode
- local $/;
- $data = <$fh>;
- }
-
- $data
- || ($cgi->param('bigfile'))
- || ($throw_error ? ThrowUserError("zero_length_file") : return 0);
-
- # Windows screenshots are usually uncompressed BMP files which
- # makes for a quick way to eat up disk space. Let's compress them.
- # We do this before we check the size since the uncompressed version
- # could easily be greater than maxattachmentsize.
- if (Bugzilla->params->{'convert_uncompressed_images'}
- && $cgi->param('contenttype') eq 'image/bmp') {
- require Image::Magick;
- my $img = Image::Magick->new(magick=>'bmp');
- $img->BlobToImage($data);
- $img->set(magick=>'png');
- my $imgdata = $img->ImageToBlob();
- $data = $imgdata;
- $cgi->param('contenttype', 'image/png');
- $hr_vars->{'convertedbmp'} = 1;
- }
+sub _check_is_private {
+ my ($invocant, $is_private) = @_;
- # Make sure the attachment does not exceed the maximum permitted size
- my $len = $data ? length($data) : 0;
- if ($maxsize && $len > $maxsize) {
- my $vars = { filesize => sprintf("%.0f", $len/1024) };
- if ($cgi->param('ispatch')) {
- $throw_error ? ThrowUserError("patch_too_large", $vars) : return 0;
- }
- else {
- $throw_error ? ThrowUserError("file_too_large", $vars) : return 0;
- }
+ $is_private = $is_private ? 1 : 0;
+ if (((!ref $invocant && $is_private)
+ || (ref $invocant && $invocant->isprivate != $is_private))
+ && !Bugzilla->user->is_insider) {
+ ThrowUserError('user_not_insider');
}
-
- return $data || '';
+ return $is_private;
}
=pod
=cut
sub get_attachments_by_bug {
- my ($class, $bug_id) = @_;
+ my ($class, $bug_id, $vars) = @_;
my $user = Bugzilla->user;
my $dbh = Bugzilla->dbh;
my $attach_ids = $dbh->selectcol_arrayref("SELECT attach_id FROM attachments
WHERE bug_id = ? $and_restriction",
undef, @values);
- my $attachments = Bugzilla::Attachment->get_list($attach_ids);
- return $attachments;
-}
-
-=pod
-
-=item C<validate_is_patch()>
-
-Description: validates the "patch" flag passed in by CGI.
-
-Returns: 1 on success.
-
-=cut
-
-sub validate_is_patch {
- my ($class, $throw_error) = @_;
- my $cgi = Bugzilla->cgi;
-
- # Set the ispatch flag to zero if it is undefined, since the UI uses
- # an HTML checkbox to represent this flag, and unchecked HTML checkboxes
- # do not get sent in HTML requests.
- $cgi->param('ispatch', $cgi->param('ispatch') ? 1 : 0);
-
- # Set the content type to text/plain if the attachment is a patch.
- $cgi->param('contenttype', 'text/plain') if $cgi->param('ispatch');
-
- return 1;
-}
-
-=pod
-
-=item C<validate_description()>
-
-Description: validates the description passed in by CGI.
-
-Returns: 1 on success.
-
-=cut
-
-sub validate_description {
- my ($class, $throw_error) = @_;
- my $cgi = Bugzilla->cgi;
-
- $cgi->param('description')
- || ($throw_error ? ThrowUserError("missing_attachment_description") : return 0);
-
- return 1;
-}
-
-=pod
-
-=item C<validate_content_type()>
-Description: validates the content type passed in by CGI.
+ my $attachments = Bugzilla::Attachment->new_from_list($attach_ids);
-Returns: 1 on success.
+ # To avoid $attachment->flags to run SQL queries itself for each
+ # attachment listed here, we collect all the data at once and
+ # populate $attachment->{flags} ourselves.
+ if ($vars->{preload}) {
+ $_->{flags} = [] foreach @$attachments;
+ my %att = map { $_->id => $_ } @$attachments;
-=cut
+ my $flags = Bugzilla::Flag->match({ bug_id => $bug_id,
+ target_type => 'attachment' });
-sub validate_content_type {
- my ($class, $throw_error) = @_;
- my $cgi = Bugzilla->cgi;
-
- if (!defined $cgi->param('contenttypemethod')) {
- $throw_error ? ThrowUserError("missing_content_type_method") : return 0;
- }
- elsif ($cgi->param('contenttypemethod') eq 'autodetect') {
- my $contenttype =
- $cgi->uploadInfo($cgi->param('data'))->{'Content-Type'};
- # The user asked us to auto-detect the content type, so use the type
- # specified in the HTTP request headers.
- if ( !$contenttype ) {
- $throw_error ? ThrowUserError("missing_content_type") : return 0;
- }
- $cgi->param('contenttype', $contenttype);
- }
- elsif ($cgi->param('contenttypemethod') eq 'list') {
- # The user selected a content type from the list, so use their
- # selection.
- $cgi->param('contenttype', $cgi->param('contenttypeselection'));
- }
- elsif ($cgi->param('contenttypemethod') eq 'manual') {
- # The user entered a content type manually, so use their entry.
- $cgi->param('contenttype', $cgi->param('contenttypeentry'));
- }
- else {
- $throw_error ?
- ThrowCodeError("illegal_content_type_method",
- { contenttypemethod => $cgi->param('contenttypemethod') }) :
- return 0;
- }
+ # Exclude flags for private attachments you cannot see.
+ @$flags = grep {exists $att{$_->attach_id}} @$flags;
- if ( $cgi->param('contenttype') !~
- /^(application|audio|image|message|model|multipart|text|video)\/.+$/ ) {
- $throw_error ?
- ThrowUserError("invalid_content_type",
- { contenttype => $cgi->param('contenttype') }) :
- return 0;
+ push(@{$att{$_->attach_id}->{flags}}, $_) foreach @$flags;
+ $attachments = [sort {$a->id <=> $b->id} values %att];
}
-
- return 1;
+ return $attachments;
}
=pod
Params: $attachment - the attachment object being edited.
$product_id - the product ID the attachment belongs to.
-Returns: 1 on success. Else an error is thrown.
+Returns: 1 on success, 0 otherwise.
=cut
my $user = Bugzilla->user;
# The submitter can edit their attachments.
- return 1 if ($attachment->attacher->id == $user->id
- || ((!$attachment->isprivate || $user->is_insider)
- && $user->in_group('editbugs', $product_id)));
-
- # If we come here, then this attachment cannot be seen by the user.
- ThrowUserError('illegal_attachment_edit', { attach_id => $attachment->id });
+ return ($attachment->attacher->id == $user->id
+ || ((!$attachment->isprivate || $user->is_insider)
+ && $user->in_group('editbugs', $product_id))) ? 1 : 0;
}
-=item C<validate_obsolete($bug)>
+=item C<validate_obsolete($bug, $attach_ids)>
Description: validates if attachments the user wants to mark as obsolete
really belong to the given bug and are not already obsolete.
he cannot view it (due to restrictions on it).
Params: $bug - The bug object obsolete attachments should belong to.
+ $attach_ids - The list of attachments to mark as obsolete.
-Returns: 1 on success. Else an error is thrown.
+Returns: The list of attachment objects to mark as obsolete.
+ Else an error is thrown.
=cut
sub validate_obsolete {
- my ($class, $bug) = @_;
- my $cgi = Bugzilla->cgi;
+ my ($class, $bug, $list) = @_;
# Make sure the attachment id is valid and the user has permissions to view
# the bug to which it is attached. Make sure also that the user can view
# the attachment itself.
my @obsolete_attachments;
- foreach my $attachid ($cgi->param('obsolete')) {
+ foreach my $attachid (@$list) {
my $vars = {};
$vars->{'attach_id'} = $attachid;
detaint_natural($attachid)
|| ThrowCodeError('invalid_attach_id_to_obsolete', $vars);
- my $attachment = Bugzilla::Attachment->get($attachid);
-
# Make sure the attachment exists in the database.
- ThrowUserError('invalid_attach_id', $vars) unless $attachment;
+ my $attachment = new Bugzilla::Attachment($attachid)
+ || ThrowUserError('invalid_attach_id', $vars);
# Check that the user can view and edit this attachment.
- $attachment->validate_can_edit($bug->product_id);
+ $attachment->validate_can_edit($bug->product_id)
+ || ThrowUserError('illegal_attachment_edit', { attach_id => $attachment->id });
$vars->{'description'} = $attachment->description;
ThrowCodeError('mismatched_bug_ids_on_obsolete', $vars);
}
- if ($attachment->isobsolete) {
- ThrowCodeError('attachment_already_obsolete', $vars);
- }
+ next if $attachment->isobsolete;
push(@obsolete_attachments, $attachment);
}
return @obsolete_attachments;
}
+###############################
+#### Constructors #####
+###############################
=pod
-=item C<insert_attachment_for_bug($throw_error, $bug, $user, $timestamp, $hr_vars)>
+=item C<create>
-Description: inserts an attachment from CGI input for the given bug.
+Description: inserts an attachment into the given bug.
-Params: C<$bug> - Bugzilla::Bug object - the bug for which to insert
+Params: takes a hashref with the following keys:
+ C<bug> - Bugzilla::Bug object - the bug for which to insert
the attachment.
- C<$user> - Bugzilla::User object - the user we're inserting an
- attachment for.
- C<$timestamp> - scalar - timestamp of the insert as returned
- by SELECT NOW().
- C<$hr_vars> - hash reference - reference to a hash of template
- variables.
-
-Returns: the ID of the new attachment.
+ C<data> - Either a filehandle pointing to the content of the
+ attachment, or the content of the attachment itself.
+ C<description> - string - describe what the attachment is about.
+ C<filename> - string - the name of the attachment (used by the
+ browser when downloading it). If the attachment is a URL, this
+ parameter has no effect.
+ C<mimetype> - string - a valid MIME type.
+ C<creation_ts> - string (optional) - timestamp of the insert
+ as returned by SELECT LOCALTIMESTAMP(0).
+ C<ispatch> - boolean (optional, default false) - true if the
+ attachment is a patch.
+ C<isprivate> - boolean (optional, default false) - true if
+ the attachment is private.
+
+Returns: The new attachment object.
=cut
-sub insert_attachment_for_bug {
- my ($class, $throw_error, $bug, $user, $timestamp, $hr_vars) = @_;
-
- my $cgi = Bugzilla->cgi;
+sub create {
+ my $class = shift;
my $dbh = Bugzilla->dbh;
- my $attachurl = $cgi->param('attachurl') || '';
- my $data;
- my $filename;
- my $contenttype;
- my $isurl;
- $class->validate_is_patch($throw_error) || return;
- $class->validate_description($throw_error) || return;
-
- if (Bugzilla->params->{'allow_attach_url'}
- && ($attachurl =~ /^(http|https|ftp):\/\/\S+/)
- && !defined $cgi->upload('data'))
- {
- $filename = '';
- $data = $attachurl;
- $isurl = 1;
- $contenttype = 'text/plain';
- $cgi->param('ispatch', 0);
- $cgi->delete('bigfile');
- }
- else {
- $filename = _validate_filename($throw_error) || return;
- # need to validate content type before data as
- # we now check the content type for image/bmp in _validate_data()
- unless ($cgi->param('ispatch')) {
- $class->validate_content_type($throw_error) || return;
-
- # Set the ispatch flag to 1 if we're set to autodetect
- # and the content type is text/x-diff or text/x-patch
- if ($cgi->param('contenttypemethod') eq 'autodetect'
- && $cgi->param('contenttype') =~ m{text/x-(?:diff|patch)})
- {
- $cgi->param('ispatch', 1);
- $cgi->param('contenttype', 'text/plain');
- }
- }
- $data = _validate_data($throw_error, $hr_vars);
- # If the attachment is stored locally, $data eq ''.
- # If an error is thrown, $data eq '0'.
- ($data ne '0') || return;
- $contenttype = $cgi->param('contenttype');
-
- # These are inserted using placeholders so no need to panic
- trick_taint($filename);
- trick_taint($contenttype);
- $isurl = 0;
- }
- # Check attachments the user tries to mark as obsolete.
- my @obsolete_attachments;
- if ($cgi->param('obsolete')) {
- @obsolete_attachments = $class->validate_obsolete($bug);
- }
+ $class->check_required_create_fields(@_);
+ my $params = $class->run_create_validators(@_);
- # The order of these function calls is important, as Flag::validate
- # assumes User::match_field has ensured that the
- # values in the requestee fields are legitimate user email addresses.
- my $match_status = Bugzilla::User::match_field($cgi, {
- '^requestee(_type)?-(\d+)$' => { 'type' => 'multi' },
- }, MATCH_SKIP_CONFIRM);
+ # Extract everything which is not a valid column name.
+ my $bug = delete $params->{bug};
+ $params->{bug_id} = $bug->id;
+ my $data = delete $params->{data};
+ my $size = delete $params->{filesize};
- $hr_vars->{'match_field'} = 'requestee';
- if ($match_status == USER_MATCH_FAILED) {
- $hr_vars->{'message'} = 'user_match_failed';
- }
- elsif ($match_status == USER_MATCH_MULTIPLE) {
- $hr_vars->{'message'} = 'user_match_multiple';
- }
+ my $attachment = $class->insert_create_data($params);
+ my $attachid = $attachment->id;
- # Escape characters in strings that will be used in SQL statements.
- my $description = $cgi->param('description');
- trick_taint($description);
- my $isprivate = $cgi->param('isprivate') ? 1 : 0;
-
- # Insert the attachment into the database.
- my $sth = $dbh->do(
- "INSERT INTO attachments
- (bug_id, creation_ts, modification_time, filename, description,
- mimetype, ispatch, isurl, isprivate, submitter_id)
- VALUES (?,?,?,?,?,?,?,?,?,?)", undef, ($bug->bug_id, $timestamp, $timestamp,
- $filename, $description, $contenttype, $cgi->param('ispatch'),
- $isurl, $isprivate, $user->id));
- # Retrieve the ID of the newly created attachment record.
- my $attachid = $dbh->bz_last_key('attachments', 'attach_id');
-
- # We only use $data here in this INSERT with a placeholder,
- # so it's safe.
- $sth = $dbh->prepare("INSERT INTO attach_data
- (id, thedata) VALUES ($attachid, ?)");
- trick_taint($data);
- $sth->bind_param(1, $data, $dbh->BLOB_TYPE);
- $sth->execute();
-
- # If the file is to be stored locally, stream the file from the web server
- # to the local file without reading it into a local variable.
- if ($cgi->param('bigfile')) {
+ # The file is too large to be stored in the DB, so we store it locally.
+ if ($size > Bugzilla->params->{'maxattachmentsize'} * 1024) {
my $attachdir = bz_locations()->{'attachdir'};
- my $fh = $cgi->upload('data');
my $hash = ($attachid % 100) + 100;
$hash =~ s/.*(\d\d)$/group.$1/;
mkdir "$attachdir/$hash", 0770;
chmod 0770, "$attachdir/$hash";
- open(AH, ">$attachdir/$hash/attachment.$attachid");
- binmode AH;
- my $sizecount = 0;
- my $limit = (Bugzilla->params->{"maxlocalattachment"} * 1048576);
- while (<$fh>) {
- print AH $_;
- $sizecount += length($_);
- if ($sizecount > $limit) {
- close AH;
- close $fh;
- unlink "$attachdir/$hash/attachment.$attachid";
- $throw_error ? ThrowUserError("local_file_too_large") : return;
- }
+ if (ref $data) {
+ copy($data, "$attachdir/$hash/attachment.$attachid");
+ close $data;
}
- close AH;
- close $fh;
+ else {
+ open(AH, '>', "$attachdir/$hash/attachment.$attachid");
+ binmode AH;
+ print AH $data;
+ close AH;
+ }
+ $data = ''; # Will be stored in the DB.
+ }
+ # If we have a filehandle, we need its content to store it in the DB.
+ elsif (ref $data) {
+ local $/;
+ # Store the content in a temp variable while we close the FH.
+ my $tmp = <$data>;
+ close $data;
+ $data = $tmp;
}
- # Make existing attachments obsolete.
- my $fieldid = get_field_id('attachments.isobsolete');
+ my $sth = $dbh->prepare("INSERT INTO attach_data
+ (id, thedata) VALUES ($attachid, ?)");
+
+ trick_taint($data);
+ $sth->bind_param(1, $data, $dbh->BLOB_TYPE);
+ $sth->execute();
+
+ $attachment->{bug} = $bug;
+
+ # Return the new attachment object.
+ return $attachment;
+}
+
+sub run_create_validators {
+ my ($class, $params) = @_;
+
+ # Let's validate the attachment content first as it may
+ # alter some other attachment attributes.
+ $params->{data} = $class->_check_data($params);
+ $params = $class->SUPER::run_create_validators($params);
+
+ $params->{creation_ts} ||= Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
+ $params->{modification_time} = $params->{creation_ts};
+ $params->{submitter_id} = Bugzilla->user->id || ThrowCodeError('invalid_user');
+
+ return $params;
+}
+
+sub update {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+ my $timestamp = shift || $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
- foreach my $obsolete_attachment (@obsolete_attachments) {
- # If the obsolete attachment has request flags, cancel them.
- # This call must be done before updating the 'attachments' table.
- Bugzilla::Flag->CancelRequests($bug, $obsolete_attachment, $timestamp);
+ my ($changes, $old_self) = $self->SUPER::update(@_);
- $dbh->do('UPDATE attachments SET isobsolete = 1, modification_time = ?
- WHERE attach_id = ?',
- undef, ($timestamp, $obsolete_attachment->id));
+ my ($removed, $added) = Bugzilla::Flag->update_flags($self, $old_self, $timestamp);
+ if ($removed || $added) {
+ $changes->{'flagtypes.name'} = [$removed, $added];
+ }
- $dbh->do('INSERT INTO bugs_activity (bug_id, attach_id, who, bug_when,
- fieldid, removed, added)
- VALUES (?,?,?,?,?,?,?)',
- undef, ($bug->bug_id, $obsolete_attachment->id, $user->id,
- $timestamp, $fieldid, 0, 1));
+ # Record changes in the activity table.
+ my $sth = $dbh->prepare('INSERT INTO bugs_activity (bug_id, attach_id, who, bug_when,
+ fieldid, removed, added)
+ VALUES (?, ?, ?, ?, ?, ?, ?)');
+
+ foreach my $field (keys %$changes) {
+ my $change = $changes->{$field};
+ $field = "attachments.$field" unless $field eq "flagtypes.name";
+ my $fieldid = get_field_id($field);
+ $sth->execute($self->bug_id, $self->id, $user->id, $timestamp,
+ $fieldid, $change->[0], $change->[1]);
}
- my $attachment = Bugzilla::Attachment->get($attachid);
-
- # 1. Add flags, if any. To avoid dying if something goes wrong
- # while processing flags, we will eval() flag validation.
- # This requires errors to die().
- # XXX: this can go away as soon as flag validation is able to
- # fail without dying.
- #
- # 2. Flag::validate() should not detect any reference to existing flags
- # when creating a new attachment. Setting the third param to -1 will
- # force this function to check this point.
- my $error_mode_cache = Bugzilla->error_mode;
- Bugzilla->error_mode(ERROR_MODE_DIE);
- eval {
- Bugzilla::Flag::validate($bug->bug_id, -1, SKIP_REQUESTEE_ON_ERROR);
- Bugzilla::Flag->process($bug, $attachment, $timestamp, $hr_vars);
- };
- Bugzilla->error_mode($error_mode_cache);
- if ($@) {
- $hr_vars->{'message'} = 'flag_creation_failed';
- $hr_vars->{'flag_creation_error'} = $@;
+ if (scalar(keys %$changes)) {
+ $dbh->do('UPDATE attachments SET modification_time = ? WHERE attach_id = ?',
+ undef, ($timestamp, $self->id));
+ $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
+ undef, ($timestamp, $self->bug_id));
}
- # Return the new attachment object.
- return $attachment;
+ return $changes;
}
=pod
$dbh->bz_start_transaction();
$dbh->do('DELETE FROM flags WHERE attach_id = ?', undef, $self->id);
$dbh->do('DELETE FROM attach_data WHERE id = ?', undef, $self->id);
- $dbh->do('UPDATE attachments SET mimetype = ?, ispatch = ?, isurl = ?, isobsolete = ?
- WHERE attach_id = ?', undef, ('text/plain', 0, 0, 1, $self->id));
+ $dbh->do('UPDATE attachments SET mimetype = ?, ispatch = ?, isobsolete = ?
+ WHERE attach_id = ?', undef, ('text/plain', 0, 1, $self->id));
$dbh->bz_commit_transaction();
}
+###############################
+#### Helpers #####
+###############################
+
+# Extract the content type from the attachment form.
+sub get_content_type {
+ my $cgi = Bugzilla->cgi;
+
+ return 'text/plain' if ($cgi->param('ispatch') || $cgi->param('attach_text'));
+
+ my $content_type;
+ if (!defined $cgi->param('contenttypemethod')) {
+ ThrowUserError("missing_content_type_method");
+ }
+ elsif ($cgi->param('contenttypemethod') eq 'autodetect') {
+ defined $cgi->upload('data') || ThrowUserError('file_not_specified');
+ # The user asked us to auto-detect the content type, so use the type
+ # specified in the HTTP request headers.
+ $content_type =
+ $cgi->uploadInfo($cgi->param('data'))->{'Content-Type'};
+ $content_type || ThrowUserError("missing_content_type");
+
+ # Set the ispatch flag to 1 if the content type
+ # is text/x-diff or text/x-patch
+ if ($content_type =~ m{text/x-(?:diff|patch)}) {
+ $cgi->param('ispatch', 1);
+ $content_type = 'text/plain';
+ }
+
+ # Internet Explorer sends image/x-png for PNG images,
+ # so convert that to image/png to match other browsers.
+ if ($content_type eq 'image/x-png') {
+ $content_type = 'image/png';
+ }
+ }
+ elsif ($cgi->param('contenttypemethod') eq 'list') {
+ # The user selected a content type from the list, so use their
+ # selection.
+ $content_type = $cgi->param('contenttypeselection');
+ }
+ elsif ($cgi->param('contenttypemethod') eq 'manual') {
+ # The user entered a content type manually, so use their entry.
+ $content_type = $cgi->param('contenttypeentry');
+ }
+ else {
+ ThrowCodeError("illegal_content_type_method",
+ { contenttypemethod => $cgi->param('contenttypemethod') });
+ }
+ return $content_type;
+}
+
+
1;
$last_reader->sends_data_to(new PatchReader::DiffPrinter::raw());
# Actually print out the patch.
print $cgi->header(-type => 'text/plain',
+ -x_content_type_options => "nosniff",
-expires => '+3M');
disable_utf8();
$reader->iterate_string('Attachment ' . $attachment->id, $attachment->data);
$last_reader->sends_data_to(new PatchReader::DiffPrinter::raw());
# Actually print out the patch.
print $cgi->header(-type => 'text/plain',
+ -x_content_type_options => "nosniff",
-expires => '+3M');
disable_utf8();
}
use Bugzilla::Constants;
use Bugzilla::Error;
+use Bugzilla::Mailer;
+use Bugzilla::Util qw(datetime_from);
+use Bugzilla::User::Setting ();
use Bugzilla::Auth::Login::Stack;
use Bugzilla::Auth::Verify::Stack;
use Bugzilla::Auth::Persist::Cookie;
# Make sure the user isn't disabled.
my $user = $login_info->{user};
- if ($user->disabledtext) {
+ if (!$user->is_enabled) {
return $self->_handle_login_result({ failure => AUTH_DISABLED,
user => $user }, $type);
}
&& $getter->user_can_create_account;
}
+sub extern_id_used {
+ my ($self) = @_;
+ return $self->{_info_getter}->extern_id_used
+ || $self->{_verifier}->extern_id_used;
+}
+
sub can_change_email {
return $_[0]->user_can_create_account;
}
my $fail_code = $result->{failure};
if (!$fail_code) {
- if ($self->{_info_getter}->{successful}->requires_persistence) {
+ # We don't persist logins over GET requests in the WebService,
+ # because the persistance information can't be re-used again.
+ # (See Bugzilla::WebService::Server::JSONRPC for more info.)
+ if ($self->{_info_getter}->{successful}->requires_persistence
+ and !Bugzilla->request_cache->{auth_no_automatic_login})
+ {
$self->{_persister}->persist_login($user);
}
}
elsif ($fail_code == AUTH_ERROR) {
- ThrowCodeError($result->{error}, $result->{details});
+ if ($result->{user_error}) {
+ ThrowUserError($result->{user_error}, $result->{details});
+ }
+ else {
+ ThrowCodeError($result->{error}, $result->{details});
+ }
}
elsif ($fail_code == AUTH_NODATA) {
$self->{_info_getter}->fail_nodata($self)
# the password was just wrong. (This makes it harder for a cracker
# to find account names by brute force)
elsif ($fail_code == AUTH_LOGINFAILED or $fail_code == AUTH_NO_SUCH_USER) {
- ThrowUserError("invalid_username_or_password");
+ my $remaining_attempts = MAX_LOGIN_ATTEMPTS
+ - ($result->{failure_count} || 0);
+ ThrowUserError("invalid_username_or_password",
+ { remaining => $remaining_attempts });
}
# The account may be disabled
elsif ($fail_code == AUTH_DISABLED) {
ThrowUserError("account_disabled",
{'disabled_reason' => $result->{user}->disabledtext});
}
+ elsif ($fail_code == AUTH_LOCKOUT) {
+ my $attempts = $user->account_ip_login_failures;
+
+ # We want to know when the account will be unlocked. This is
+ # determined by the 5th-from-last login failure (or more/less than
+ # 5th, if MAX_LOGIN_ATTEMPTS is not 5).
+ my $determiner = $attempts->[scalar(@$attempts) - MAX_LOGIN_ATTEMPTS];
+ my $unlock_at = datetime_from($determiner->{login_time},
+ Bugzilla->local_timezone);
+ $unlock_at->add(minutes => LOGIN_LOCKOUT_INTERVAL);
+
+ # If we were *just* locked out, notify the maintainer about the
+ # lockout.
+ if ($result->{just_locked_out}) {
+ # We're sending to the maintainer, who may be not a Bugzilla
+ # account, but just an email address. So we use the
+ # installation's default language for sending the email.
+ my $default_settings = Bugzilla::User::Setting::get_defaults();
+ my $template = Bugzilla->template_inner(
+ $default_settings->{lang}->{default_value});
+ my $vars = {
+ locked_user => $user,
+ attempts => $attempts,
+ unlock_at => $unlock_at,
+ };
+ my $message;
+ $template->process('email/lockout.txt.tmpl', $vars, \$message)
+ || ThrowTemplateError($template->error);
+ MessageToMTA($message);
+ }
+
+ $unlock_at->set_time_zone($user->timezone);
+ ThrowUserError('account_locked',
+ { ip_addr => $determiner->{ip_addr}, unlock_at => $unlock_at });
+ }
# If we get here, then we've run out of options, which shouldn't happen.
else {
ThrowCodeError("authres_unhandled", { value => $fail_code });
An incorrect username or password was given.
+The hashref may also contain a C<failure_count> element, which specifies
+how many times the account has failed to log in within the lockout
+period (see L</AUTH_LOCKOUT>). This is used to warn the user when
+he is getting close to being locked out.
+
=head2 C<AUTH_NO_SUCH_USER>
This is an optional more-specific version of C<AUTH_LOGINFAILED>.
The user successfully logged in, but their account has been disabled.
Usually this is throw only by C<Bugzilla::Auth::login>.
+=head2 C<AUTH_LOCKOUT>
+
+The user's account is locked out after having failed to log in too many
+times within a certain period of time (as specified by
+L<Bugzilla::Constants/LOGIN_LOCKOUT_INTERVAL>).
+
+The hashref will also contain a C<user> element, representing the
+L<Bugzilla::User> whose account is locked out.
+
=head1 LOGIN TYPES
The C<login> function (below) can do different types of login, depending
Returns: C<true> if users are allowed to create new Bugzilla accounts,
C<false> otherwise.
+=item C<extern_id_used>
+
+Description: Whether or not current login system uses extern_id.
+
=item C<can_change_email>
Description: Whether or not the current login system allows users to
use constant requires_persistence => 1;
use constant requires_verification => 1;
use constant user_can_create_account => 0;
+use constant is_automatic => 0;
+use constant extern_id_used => 0;
sub new {
my ($class) = @_;
Whether or not users can create accounts, if this login method is
currently being used by the system. Defaults to C<false>.
+=item C<is_automatic>
+
+True if this login method requires no interaction from the user within
+Bugzilla. (For example, C<Env> auth is "automatic" because the webserver
+just passes us an environment variable on most page requests, and does not
+ask the user for authentication information directly in Bugzilla.) Defaults
+to C<false>.
+
+=item C<extern_id_used>
+
+Whether or not this login method uses the extern_id field. If
+used, users with editusers permission will be be allowed to
+edit the extern_id for all users.
+
+The default value is C<0>.
+
=back
sub get_login_info {
my ($self) = @_;
- my $cgi = Bugzilla->cgi;
-
- my $username = trim($cgi->param("Bugzilla_login"));
- my $password = $cgi->param("Bugzilla_password");
+ my $params = Bugzilla->input_params;
- $cgi->delete('Bugzilla_login', 'Bugzilla_password');
+ my $username = trim(delete $params->{"Bugzilla_login"});
+ my $password = delete $params->{"Bugzilla_password"};
if (!defined $username || !defined $password) {
return { failure => AUTH_NODATA };
my $cgi = Bugzilla->cgi;
my $template = Bugzilla->template;
- if (Bugzilla->error_mode == Bugzilla::Constants::ERROR_MODE_DIE_SOAP_FAULT) {
- die SOAP::Fault
- ->faultcode(ERROR_AUTH_NODATA)
- ->faultstring('Login Required');
- }
-
- # If system is not configured to never require SSL connections
- # we want to always redirect to SSL since passing usernames and
- # passwords over an unprotected connection is a bad idea. If we
- # get here then a login form will be provided to the user so we
- # want this to be protected if possible.
- if ($cgi->protocol ne 'https' && Bugzilla->params->{'sslbase'} ne ''
- && Bugzilla->params->{'ssl'} ne 'never')
- {
- $cgi->require_https(Bugzilla->params->{'sslbase'});
+ if (Bugzilla->usage_mode != USAGE_MODE_BROWSER) {
+ ThrowUserError('login_required');
}
print $cgi->header();
use constant requires_persistence => 0;
use constant requires_verification => 0;
use constant can_login => 0;
+use constant is_automatic => 1;
# Note that Cookie never consults the Verifier, it always assumes
# it has a valid DB account or it fails.
my $cgi = Bugzilla->cgi;
my $dbh = Bugzilla->dbh;
- my $ip_addr = $cgi->remote_addr();
- my $net_addr = get_netaddr($ip_addr);
+ my $ip_addr = remote_ip();
my $login_cookie = $cgi->cookie("Bugzilla_logincookie");
my $user_id = $cgi->cookie("Bugzilla_login");
trick_taint($login_cookie);
detaint_natural($user_id);
- my $query = "SELECT userid
- FROM logincookies
- WHERE logincookies.cookie = ?
- AND logincookies.userid = ?
- AND (logincookies.ipaddr = ?";
-
- # If we have a network block that's allowed to use this cookie,
- # as opposed to just a single IP.
- my @params = ($login_cookie, $user_id, $ip_addr);
- if (defined $net_addr) {
- trick_taint($net_addr);
- $query .= " OR logincookies.ipaddr = ?";
- push(@params, $net_addr);
- }
- $query .= ")";
+ my $is_valid =
+ $dbh->selectrow_array('SELECT 1
+ FROM logincookies
+ WHERE cookie = ?
+ AND userid = ?
+ AND (ipaddr = ? OR ipaddr IS NULL)',
+ undef, ($login_cookie, $user_id, $ip_addr));
# If the cookie is valid, return a valid username.
- if ($dbh->selectrow_array($query, undef, @params)) {
+ if ($is_valid) {
# If we logged in successfully, then update the lastused
# time on the login cookie
$dbh->do("UPDATE logincookies SET lastused = NOW()
use constant can_logout => 0;
use constant can_login => 0;
+use constant requires_persistence => 0;
use constant requires_verification => 0;
+use constant is_automatic => 1;
+use constant extern_id_used => 1;
sub get_login_info {
my ($self) = @_;
_stack
successful
);
+use Hash::Util qw(lock_keys);
+use Bugzilla::Hook;
+use Bugzilla::Constants;
+use List::MoreUtils qw(any);
sub new {
my $class = shift;
my $self = $class->SUPER::new(@_);
my $list = shift;
+ my %methods = map { $_ => "Bugzilla/Auth/Login/$_.pm" } split(',', $list);
+ lock_keys(%methods);
+ Bugzilla::Hook::process('auth_login_methods', { modules => \%methods });
+
$self->{_stack} = [];
foreach my $login_method (split(',', $list)) {
- require "Bugzilla/Auth/Login/${login_method}.pm";
- push(@{$self->{_stack}},
- "Bugzilla::Auth::Login::$login_method"->new(@_));
+ my $module = $methods{$login_method};
+ require $module;
+ $module =~ s|/|::|g;
+ $module =~ s/.pm$//;
+ push(@{$self->{_stack}}, $module->new(@_));
}
return $self;
}
my $self = shift;
my $result;
foreach my $object (@{$self->{_stack}}) {
+ # See Bugzilla::WebService::Server::JSONRPC for where and why
+ # auth_no_automatic_login is used.
+ if (Bugzilla->request_cache->{auth_no_automatic_login}) {
+ next if $object->is_automatic;
+ }
$result = $object->get_login_info(@_);
$self->{successful} = $object;
- last if !$result->{failure};
- # So that if none of them succeed, it's undef.
+
+ # We only carry on down the stack if this method denied all knowledge.
+ last unless ($result->{failure}
+ && ($result->{failure} eq AUTH_NODATA
+ || $result->{failure} eq AUTH_NO_SUCH_USER));
+
+ # If none of the methods succeed, it's undef.
$self->{successful} = undef;
}
return $result;
return 0;
}
+sub extern_id_used {
+ my ($self) = @_;
+ return any { $_->extern_id_used } @{ $self->{_stack} };
+}
+
1;
my ($self, $user) = @_;
my $dbh = Bugzilla->dbh;
my $cgi = Bugzilla->cgi;
-
- my $ip_addr = $cgi->remote_addr;
- unless ($cgi->param('Bugzilla_restrictlogin') ||
- Bugzilla->params->{'loginnetmask'} == 32)
- {
- $ip_addr = get_netaddr($ip_addr);
+ my $input_params = Bugzilla->input_params;
+
+ my $ip_addr;
+ if ($input_params->{'Bugzilla_restrictlogin'}) {
+ $ip_addr = remote_ip();
+ # The IP address is valid, at least for comparing with itself in a
+ # subsequent login
+ trick_taint($ip_addr);
}
- # The IP address is valid, at least for comparing with itself in a
- # subsequent login
- trick_taint($ip_addr);
-
$dbh->bz_start_transaction();
my $login_cookie =
# Issuing a new cookie is a good time to clean up the old
# cookies.
- $dbh->do("DELETE FROM logincookies WHERE lastused < LOCALTIMESTAMP(0) - "
- . $dbh->sql_interval(MAX_LOGINCOOKIE_AGE, 'DAY'));
+ $dbh->do("DELETE FROM logincookies WHERE lastused < "
+ . $dbh->sql_date_math('LOCALTIMESTAMP(0)', '-',
+ MAX_LOGINCOOKIE_AGE, 'DAY'));
$dbh->bz_commit_transaction();
# or admin didn't forbid it and user told to remember.
if ( Bugzilla->params->{'rememberlogin'} eq 'on' ||
(Bugzilla->params->{'rememberlogin'} ne 'off' &&
- $cgi->param('Bugzilla_remember') &&
- $cgi->param('Bugzilla_remember') eq 'on') )
+ $input_params->{'Bugzilla_remember'} &&
+ $input_params->{'Bugzilla_remember'} eq 'on') )
{
# Not a session cookie, so set an infinite expiry
$cookieargs{'-expires'} = 'Fri, 01-Jan-2038 00:00:00 GMT';
}
- if (Bugzilla->params->{'ssl'} ne 'never'
- && Bugzilla->params->{'sslbase'} ne '')
- {
- # Bugzilla->login will automatically redirect to https://,
- # so it's safe to turn on the 'secure' bit.
+ if (Bugzilla->params->{'ssl_redirect'}) {
+ # Make these cookies only be sent to us by the browser during
+ # HTTPS sessions, if we're using SSL.
$cookieargs{'-secure'} = 1;
}
my $cgi = Bugzilla->cgi;
$cgi->remove_cookie('Bugzilla_login');
$cgi->remove_cookie('Bugzilla_logincookie');
+ $cgi->remove_cookie('sudo');
}
1;
use Bugzilla::Util;
use constant user_can_create_account => 1;
+use constant extern_id_used => 0;
sub new {
my ($class, $login_type) = @_;
Whether or not users can manually create accounts in this type of
account source. Defaults to C<true>.
+=item C<extern_id_used>
+
+Whether or not this verifier method uses the extern_id field. If
+used, users with editusers permission will be be allowed to
+edit the extern_id for all users.
+
+The default value is C<false>.
+
=back
my $dbh = Bugzilla->dbh;
my $username = $login_data->{username};
- my $user_id = login_to_id($username);
+ my $user = new Bugzilla::User({ name => $username });
- return { failure => AUTH_NO_SUCH_USER } unless $user_id;
+ return { failure => AUTH_NO_SUCH_USER } unless $user;
- $login_data->{bz_username} = $username;
- my $password = $login_data->{password};
-
- trick_taint($username);
- my ($real_password_crypted) = $dbh->selectrow_array(
- "SELECT cryptpassword FROM profiles WHERE userid = ?",
- undef, $user_id);
+ $login_data->{user} = $user;
+ $login_data->{bz_username} = $user->login;
- # Wide characters cause crypt to die
- if (Bugzilla->params->{'utf8'}) {
- utf8::encode($password) if utf8::is_utf8($password);
+ if ($user->account_is_locked_out) {
+ return { failure => AUTH_LOCKOUT, user => $user };
}
+ my $password = $login_data->{password};
+ my $real_password_crypted = $user->cryptpassword;
+
# Using the internal crypted password as the salt,
# crypt the password the user entered.
- my $entered_password_crypted = crypt($password, $real_password_crypted);
-
- return { failure => AUTH_LOGINFAILED }
- if $entered_password_crypted ne $real_password_crypted;
+ my $entered_password_crypted = bz_crypt($password, $real_password_crypted);
+
+ if ($entered_password_crypted ne $real_password_crypted) {
+ # Record the login failure
+ $user->note_login_failure();
+
+ # Immediately check if we are locked out
+ if ($user->account_is_locked_out) {
+ return { failure => AUTH_LOCKOUT, user => $user,
+ just_locked_out => 1 };
+ }
+
+ return { failure => AUTH_LOGINFAILED,
+ failure_count => scalar(@{ $user->account_ip_login_failures }),
+ };
+ }
+
+ # Force the user to type a longer password if it's too short.
+ if (length($password) < USER_PASSWORD_MIN_LENGTH) {
+ return { failure => AUTH_ERROR, user_error => 'password_current_too_short',
+ details => { locked_user => $user } };
+ }
# The user's credentials are okay, so delete any outstanding
- # password tokens they may have generated.
- Bugzilla::Token::DeletePasswordTokens($user_id, "user_logged_in");
+ # password tokens or login failures they may have generated.
+ Bugzilla::Token::DeletePasswordTokens($user->id, "user_logged_in");
+ $user->clear_login_failures();
+
+ # If their old password was using crypt() or some different hash
+ # than we're using now, convert the stored password to using
+ # whatever hashing system we're using now.
+ my $current_algorithm = PASSWORD_DIGEST_ALGORITHM;
+ if ($real_password_crypted !~ /{\Q$current_algorithm\E}$/) {
+ $user->set_password($password);
+ $user->update();
+ }
return $login_data;
}
# just appending the Base DN to the uid isn't sufficient to get the
# user's DN. For servers which don't work this way, there will still
# be no harm done.
- $self->_bind_ldap_anonymously();
+ $self->_bind_ldap_for_search();
# Now, we verify that the user exists, and get a LDAP Distinguished
# Name for the user.
return { failure => AUTH_LOGINFAILED } if $pw_result->code;
# And now we fill in the user's details.
+
+ # First try the search as the (already bound) user in question.
+ my $user_entry;
+ my $error_string;
my $detail_result = $self->ldap->search(_bz_search_params($username));
+ if ($detail_result->code) {
+ # Stash away the original error, just in case
+ $error_string = $detail_result->error;
+ } else {
+ $user_entry = $detail_result->shift_entry;
+ }
+
+ # If that failed (either because the search failed, or returned no
+ # results) then try re-binding as the initial search user, but only
+ # if the LDAPbinddn parameter is set.
+ if (!$user_entry && Bugzilla->params->{"LDAPbinddn"}) {
+ $self->_bind_ldap_for_search();
+
+ $detail_result = $self->ldap->search(_bz_search_params($username));
+ if (!$detail_result->code) {
+ $user_entry = $detail_result->shift_entry;
+ }
+ }
+
+ # If we *still* don't have anything in $user_entry then give up.
return { failure => AUTH_ERROR, error => "ldap_search_error",
- details => {errstr => $detail_result->error, username => $username}
- } if $detail_result->code;
+ details => {errstr => $error_string, username => $username}
+ } if !$user_entry;
- my $user_entry = $detail_result->shift_entry;
my $mail_attr = Bugzilla->params->{"LDAPmailattribute"};
if ($mail_attr) {
. Bugzilla->params->{"LDAPfilter"} . ')');
}
-sub _bind_ldap_anonymously {
+sub _bind_ldap_for_search {
my ($self) = @_;
my $bind_result;
if (Bugzilla->params->{"LDAPbinddn"}) {
successful
);
+use Bugzilla::Hook;
+
+use Hash::Util qw(lock_keys);
+use List::MoreUtils qw(any);
+
sub new {
my $class = shift;
my $list = shift;
my $self = $class->SUPER::new(@_);
+ my %methods = map { $_ => "Bugzilla/Auth/Verify/$_.pm" } split(',', $list);
+ lock_keys(%methods);
+ Bugzilla::Hook::process('auth_verify_methods', { modules => \%methods });
+
$self->{_stack} = [];
foreach my $verify_method (split(',', $list)) {
- require "Bugzilla/Auth/Verify/${verify_method}.pm";
- push(@{$self->{_stack}},
- "Bugzilla::Auth::Verify::$verify_method"->new(@_));
+ my $module = $methods{$verify_method};
+ require $module;
+ $module =~ s|/|::|g;
+ $module =~ s/.pm$//;
+ push(@{$self->{_stack}}, $module->new(@_));
}
return $self;
}
return 0;
}
+sub extern_id_used {
+ my ($self) = @_;
+ return any { $_->extern_id_used } @{ $self->{_stack} };
+}
+
1;
# Max Kanat-Alexander <mkanat@bugzilla.org>
# Frédéric Buclin <LpSolit@gmail.com>
# Lance Larsh <lance.larsh@oracle.com>
+# Elliotte Martin <elliotte_martin@yahoo.com>
+# Christian Legnitto <clegnitto@mozilla.com>
package Bugzilla::Bug;
use Bugzilla::FlagType;
use Bugzilla::Hook;
use Bugzilla::Keyword;
+use Bugzilla::Milestone;
use Bugzilla::User;
use Bugzilla::Util;
+use Bugzilla::Version;
use Bugzilla::Error;
use Bugzilla::Product;
use Bugzilla::Component;
use Bugzilla::Group;
use Bugzilla::Status;
+use Bugzilla::Comment;
+use Bugzilla::BugUrl;
-use List::Util qw(min);
+use List::MoreUtils qw(firstidx uniq part);
+use List::Util qw(min max first);
use Storable qw(dclone);
+use URI;
+use URI::QueryParam;
+use Scalar::Util qw(blessed);
use base qw(Bugzilla::Object Exporter);
@Bugzilla::Bug::EXPORT = qw(
- bug_alias_to_id ValidateBugID
- RemoveVotes CheckIfVotedConfirmed
+ bug_alias_to_id
LogActivityEntry
editable_bug_fields
- SPECIAL_STATUS_WORKFLOW_ACTIONS
);
#####################################################################
use constant ID_FIELD => 'bug_id';
use constant NAME_FIELD => 'alias';
use constant LIST_ORDER => ID_FIELD;
+# Bugs have their own auditing table, bugs_activity.
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
# This is a sub because it needs to call other subroutines.
sub DB_COLUMNS {
my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT}
Bugzilla->active_custom_fields;
my @custom_names = map {$_->name} @custom;
- return qw(
+
+ my @columns = (qw(
alias
assigned_to
bug_file_loc
delta_ts
estimated_time
everconfirmed
+ lastdiffed
op_sys
priority
product_id
'reporter AS reporter_id',
$dbh->sql_date_format('creation_ts', '%Y.%m.%d %H:%i') . ' AS creation_ts',
$dbh->sql_date_format('deadline', '%Y-%m-%d') . ' AS deadline',
- @custom_names;
+ @custom_names);
+
+ Bugzilla::Hook::process("bug_columns", { columns => \@columns });
+
+ return @columns;
}
-use constant REQUIRED_CREATE_FIELDS => qw(
- component
- product
- short_desc
- version
-);
-
-# There are also other, more complex validators that are called
-# from run_create_validators.
sub VALIDATORS {
+
my $validators = {
alias => \&_check_alias,
+ assigned_to => \&_check_assigned_to,
bug_file_loc => \&_check_bug_file_loc,
- bug_severity => \&_check_bug_severity,
+ bug_severity => \&_check_select_field,
+ bug_status => \&_check_bug_status,
+ cc => \&_check_cc,
comment => \&_check_comment,
- commentprivacy => \&_check_commentprivacy,
+ component => \&_check_component,
+ creation_ts => \&_check_creation_ts,
deadline => \&_check_deadline,
- estimated_time => \&_check_estimated_time,
- op_sys => \&_check_op_sys,
+ dup_id => \&_check_dup_id,
+ estimated_time => \&_check_time_field,
+ everconfirmed => \&Bugzilla::Object::check_boolean,
+ groups => \&_check_groups,
+ keywords => \&_check_keywords,
+ op_sys => \&_check_select_field,
priority => \&_check_priority,
product => \&_check_product,
- remaining_time => \&_check_remaining_time,
- rep_platform => \&_check_rep_platform,
+ qa_contact => \&_check_qa_contact,
+ remaining_time => \&_check_time_field,
+ rep_platform => \&_check_select_field,
+ resolution => \&_check_resolution,
short_desc => \&_check_short_desc,
status_whiteboard => \&_check_status_whiteboard,
+ target_milestone => \&_check_target_milestone,
+ version => \&_check_version,
+
+ cclist_accessible => \&Bugzilla::Object::check_boolean,
+ reporter_accessible => \&Bugzilla::Object::check_boolean,
};
# Set up validators for custom fields.
elsif ($field->type == FIELD_TYPE_FREETEXT) {
$validator = \&_check_freetext_field;
}
+ elsif ($field->type == FIELD_TYPE_BUG_ID) {
+ $validator = \&_check_bugid_field;
+ }
else {
$validator = \&_check_default_field;
}
return $validators;
};
-use constant UPDATE_VALIDATORS => {
- assigned_to => \&_check_assigned_to,
- bug_status => \&_check_bug_status,
- cclist_accessible => \&Bugzilla::Object::check_boolean,
- dup_id => \&_check_dup_id,
- qa_contact => \&_check_qa_contact,
- reporter_accessible => \&Bugzilla::Object::check_boolean,
- resolution => \&_check_resolution,
- target_milestone => \&_check_target_milestone,
- version => \&_check_version,
+sub VALIDATOR_DEPENDENCIES {
+ my $cache = Bugzilla->request_cache;
+ return $cache->{bug_validator_dependencies}
+ if $cache->{bug_validator_dependencies};
+
+ my %deps = (
+ assigned_to => ['component'],
+ bug_status => ['product', 'comment', 'target_milestone'],
+ cc => ['component'],
+ comment => ['creation_ts'],
+ component => ['product'],
+ dup_id => ['bug_status', 'resolution'],
+ groups => ['product'],
+ keywords => ['product'],
+ resolution => ['bug_status'],
+ qa_contact => ['component'],
+ target_milestone => ['product'],
+ version => ['product'],
+ );
+
+ foreach my $field (@{ Bugzilla->fields }) {
+ $deps{$field->name} = [ $field->visibility_field->name ]
+ if $field->{visibility_field_id};
+ }
+
+ $cache->{bug_validator_dependencies} = \%deps;
+ return \%deps;
};
sub UPDATE_COLUMNS {
);
sub DATE_COLUMNS {
- my @fields = Bugzilla->get_fields(
- { custom => 1, type => FIELD_TYPE_DATETIME });
+ my @fields = @{ Bugzilla->fields({ type => FIELD_TYPE_DATETIME }) };
return map { $_->name } @fields;
}
-# This is used by add_comment to know what we validate before putting in
-# the DB.
-use constant UPDATE_COMMENT_COLUMNS => qw(
- thetext
- work_time
- type
- extra_data
- isprivate
-);
-
# Used in LogActivityEntry(). Gives the max length of lines in the
# activity table.
use constant MAX_LINE_LENGTH => 254;
-use constant SPECIAL_STATUS_WORKFLOW_ACTIONS => qw(
- none
- duplicate
- change_resolution
- clearresolution
-);
+# This maps the names of internal Bugzilla bug fields to things that would
+# make sense to somebody who's not intimately familiar with the inner workings
+# of Bugzilla. (These are the field names that the WebService and email_in.pl
+# use.)
+use constant FIELD_MAP => {
+ blocks => 'blocked',
+ cc_accessible => 'cclist_accessible',
+ commentprivacy => 'comment_is_private',
+ creation_time => 'creation_ts',
+ creator => 'reporter',
+ description => 'comment',
+ depends_on => 'dependson',
+ dupe_of => 'dup_id',
+ id => 'bug_id',
+ is_confirmed => 'everconfirmed',
+ is_cc_accessible => 'cclist_accessible',
+ is_creator_accessible => 'reporter_accessible',
+ last_change_time => 'delta_ts',
+ platform => 'rep_platform',
+ severity => 'bug_severity',
+ status => 'bug_status',
+ summary => 'short_desc',
+ url => 'bug_file_loc',
+ whiteboard => 'status_whiteboard',
+
+ # These are special values for the WebService Bug.search method.
+ limit => 'LIMIT',
+ offset => 'OFFSET',
+};
+
+use constant REQUIRED_FIELD_MAP => {
+ product_id => 'product',
+ component_id => 'component',
+};
+
+# Creation timestamp is here because it needs to be validated
+# but it can be NULL in the database (see comments in create above)
+#
+# Target Milestone is here because it has a default that the validator
+# creates (product.defaultmilestone) that is different from the database
+# default.
+#
+# CC is here because it is a separate table, and has a validator-created
+# default of the component initialcc.
+#
+# QA Contact is allowed to be NULL in the database, so it wouldn't normally
+# be caught by _required_create_fields. However, it always has to be validated,
+# because it has a default of the component.defaultqacontact.
+#
+# Groups are in a separate table, but must always be validated so that
+# mandatory groups get set on bugs.
+use constant EXTRA_REQUIRED_FIELDS => qw(creation_ts target_milestone cc qa_contact groups);
#####################################################################
+# This and "new" catch every single way of creating a bug, so that we
+# can call _create_cf_accessors.
+sub _do_list_select {
+ my $invocant = shift;
+ $invocant->_create_cf_accessors();
+ return $invocant->SUPER::_do_list_select(@_);
+}
+
sub new {
my $invocant = shift;
my $class = ref($invocant) || $invocant;
my $param = shift;
+ $class->_create_cf_accessors();
+
+ # Remove leading "#" mark if we've just been passed an id.
+ if (!ref $param && $param =~ /^#(\d+)$/) {
+ $param = $1;
+ }
+
# If we get something that looks like a word (not a number),
# make it the "name" param.
if (!defined $param || (!ref($param) && $param !~ /^\d+$/)) {
# if the bug wasn't found in the database.
if (!$self) {
my $error_self = {};
+ if (ref $param) {
+ $error_self->{bug_id} = $param->{name};
+ $error_self->{error} = 'InvalidBugId';
+ }
+ else {
+ $error_self->{bug_id} = $param;
+ $error_self->{error} = 'NotFound';
+ }
bless $error_self, $class;
- $error_self->{'bug_id'} = ref($param) ? $param->{name} : $param;
- $error_self->{'error'} = 'NotFound';
return $error_self;
}
return $self;
}
+sub check {
+ my $class = shift;
+ my ($id, $field) = @_;
+
+ ThrowUserError('improper_bug_id_field_value', { field => $field }) unless defined $id;
+
+ # Bugzilla::Bug throws lots of special errors, so we don't call
+ # SUPER::check, we just call our new and do our own checks.
+ my $self = $class->new(trim($id));
+ # For error messages, use the id that was returned by new(), because
+ # it's cleaned up.
+ $id = $self->id;
+
+ if ($self->{error}) {
+ if ($self->{error} eq 'NotFound') {
+ ThrowUserError("bug_id_does_not_exist", { bug_id => $id });
+ }
+ if ($self->{error} eq 'InvalidBugId') {
+ ThrowUserError("improper_bug_id_field_value",
+ { bug_id => $id,
+ field => $field });
+ }
+ }
+
+ unless ($field && $field =~ /^(dependson|blocked|dup_id)$/) {
+ $self->check_is_visible;
+ }
+ return $self;
+}
+
+sub check_is_visible {
+ my $self = shift;
+ my $user = Bugzilla->user;
+
+ if (!$user->can_see_bug($self->id)) {
+ # The error the user sees depends on whether or not they are
+ # logged in (i.e. $user->id contains the user's positive integer ID).
+ if ($user->id) {
+ ThrowUserError("bug_access_denied", { bug_id => $self->id });
+ } else {
+ ThrowUserError("bug_access_query", { bug_id => $self->id });
+ }
+ }
+}
+
+sub match {
+ my $class = shift;
+ my ($params) = @_;
+
+ # Allow matching certain fields by name (in addition to matching by ID).
+ my %translate_fields = (
+ assigned_to => 'Bugzilla::User',
+ qa_contact => 'Bugzilla::User',
+ reporter => 'Bugzilla::User',
+ product => 'Bugzilla::Product',
+ component => 'Bugzilla::Component',
+ );
+ my %translated;
+
+ foreach my $field (keys %translate_fields) {
+ my @ids;
+ # Convert names to ids. We use "exists" everywhere since people can
+ # legally specify "undef" to mean IS NULL (even though most of these
+ # fields can't be NULL, people can still specify it...).
+ if (exists $params->{$field}) {
+ my $names = $params->{$field};
+ my $type = $translate_fields{$field};
+ my $param = $type eq 'Bugzilla::User' ? 'login_name' : 'name';
+ # We call Bugzilla::Object::match directly to avoid the
+ # Bugzilla::User::match implementation which is different.
+ my $objects = Bugzilla::Object::match($type, { $param => $names });
+ push(@ids, map { $_->id } @$objects);
+ }
+ # You can also specify ids directly as arguments to this function,
+ # so include them in the list if they have been specified.
+ if (exists $params->{"${field}_id"}) {
+ my $current_ids = $params->{"${field}_id"};
+ my @id_array = ref $current_ids ? @$current_ids : ($current_ids);
+ push(@ids, @id_array);
+ }
+ # We do this "or" instead of a "scalar(@ids)" to handle the case
+ # when people passed only invalid object names. Otherwise we'd
+ # end up with a SUPER::match call with zero criteria (which dies).
+ if (exists $params->{$field} or exists $params->{"${field}_id"}) {
+ $translated{$field} = scalar(@ids) == 1 ? $ids[0] : \@ids;
+ }
+ }
+
+ # The user fields don't have an _id on the end of them in the database,
+ # but the product & component fields do, so we have to have separate
+ # code to deal with the different sets of fields here.
+ foreach my $field (qw(assigned_to qa_contact reporter)) {
+ delete $params->{"${field}_id"};
+ $params->{$field} = $translated{$field}
+ if exists $translated{$field};
+ }
+ foreach my $field (qw(product component)) {
+ delete $params->{$field};
+ $params->{"${field}_id"} = $translated{$field}
+ if exists $translated{$field};
+ }
+
+ return $class->SUPER::match(@_);
+}
+
+# Helps load up information for bugs for show_bug.cgi and other situations
+# that will need to access info on lots of bugs.
+sub preload {
+ my ($class, $bugs) = @_;
+ my $user = Bugzilla->user;
+
+ # It would be faster but MUCH more complicated to select all the
+ # deps for the entire list in one SQL statement. If we ever have
+ # a profile that proves that that's necessary, we can switch over
+ # to the more complex method.
+ my @all_dep_ids;
+ foreach my $bug (@$bugs) {
+ push(@all_dep_ids, @{ $bug->blocked }, @{ $bug->dependson });
+ }
+ @all_dep_ids = uniq @all_dep_ids;
+ # If we don't do this, can_see_bug will do one call per bug in
+ # the dependency lists, during get_bug_link in Bugzilla::Template.
+ $user->visible_bugs(\@all_dep_ids);
+}
+
+sub possible_duplicates {
+ my ($class, $params) = @_;
+ my $short_desc = $params->{summary};
+ my $products = $params->{products} || [];
+ my $limit = $params->{limit} || MAX_POSSIBLE_DUPLICATES;
+ $limit = MAX_POSSIBLE_DUPLICATES if $limit > MAX_POSSIBLE_DUPLICATES;
+ $products = [$products] if !ref($products) eq 'ARRAY';
+
+ my $orig_limit = $limit;
+ detaint_natural($limit)
+ || ThrowCodeError('param_must_be_numeric',
+ { function => 'possible_duplicates',
+ param => $orig_limit });
+
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+ my @words = split(/[\b\s]+/, $short_desc || '');
+ # Exclude punctuation from the array.
+ @words = map { /(\w+)/; $1 } @words;
+ # And make sure that each word is longer than 2 characters.
+ @words = grep { defined $_ and length($_) > 2 } @words;
+
+ return [] if !@words;
+
+ my ($where_sql, $relevance_sql);
+ if ($dbh->FULLTEXT_OR) {
+ my $joined_terms = join($dbh->FULLTEXT_OR, @words);
+ ($where_sql, $relevance_sql) =
+ $dbh->sql_fulltext_search('bugs_fulltext.short_desc',
+ $joined_terms, 1);
+ $relevance_sql ||= $where_sql;
+ }
+ else {
+ my (@where, @relevance);
+ my $count = 0;
+ foreach my $word (@words) {
+ $count++;
+ my ($term, $rel_term) = $dbh->sql_fulltext_search(
+ 'bugs_fulltext.short_desc', $word, $count);
+ push(@where, $term);
+ push(@relevance, $rel_term || $term);
+ }
+
+ $where_sql = join(' OR ', @where);
+ $relevance_sql = join(' + ', @relevance);
+ }
+
+ my $product_ids = join(',', map { $_->id } @$products);
+ my $product_sql = $product_ids ? "AND product_id IN ($product_ids)" : "";
+
+ # Because we collapse duplicates, we want to get slightly more bugs
+ # than were actually asked for.
+ my $sql_limit = $limit + 5;
+
+ my $possible_dupes = $dbh->selectall_arrayref(
+ "SELECT bugs.bug_id AS bug_id, bugs.resolution AS resolution,
+ ($relevance_sql) AS relevance
+ FROM bugs
+ INNER JOIN bugs_fulltext ON bugs.bug_id = bugs_fulltext.bug_id
+ WHERE ($where_sql) $product_sql
+ ORDER BY relevance DESC, bug_id DESC " .
+ $dbh->sql_limit($sql_limit), {Slice=>{}});
+
+ my @actual_dupe_ids;
+ # Resolve duplicates into their ultimate target duplicates.
+ foreach my $bug (@$possible_dupes) {
+ my $push_id = $bug->{bug_id};
+ if ($bug->{resolution} && $bug->{resolution} eq 'DUPLICATE') {
+ $push_id = _resolve_ultimate_dup_id($bug->{bug_id});
+ }
+ push(@actual_dupe_ids, $push_id);
+ }
+ @actual_dupe_ids = uniq @actual_dupe_ids;
+ if (scalar @actual_dupe_ids > $limit) {
+ @actual_dupe_ids = @actual_dupe_ids[0..($limit-1)];
+ }
+
+ my $visible = $user->visible_bugs(\@actual_dupe_ids);
+ return $class->new_from_list($visible);
+}
+
# Docs for create() (there's no POD in this file yet, but we very
# much need this documented right now):
#
# These are not a fields in the bugs table, so we don't pass them to
# insert_create_data.
- my $cc_ids = delete $params->{cc};
- my $groups = delete $params->{groups};
- my $depends_on = delete $params->{dependson};
- my $blocked = delete $params->{blocked};
- my ($comment, $privacy) = ($params->{comment}, $params->{commentprivacy});
- delete $params->{comment};
- delete $params->{commentprivacy};
-
- # Set up the keyword cache for bug creation.
- my $keywords = $params->{keywords};
- $params->{keywords} = join(', ', sort {lc($a) cmp lc($b)}
- map($_->name, @$keywords));
+ my $cc_ids = delete $params->{cc};
+ my $groups = delete $params->{groups};
+ my $depends_on = delete $params->{dependson};
+ my $blocked = delete $params->{blocked};
+ my $keywords = delete $params->{keywords};
+ my $creation_comment = delete $params->{comment};
# We don't want the bug to appear in the system until it's correctly
# protected by groups.
# Add the group restrictions
my $sth_group = $dbh->prepare(
'INSERT INTO bug_group_map (bug_id, group_id) VALUES (?, ?)');
- foreach my $group_id (@$groups) {
- $sth_group->execute($bug->bug_id, $group_id);
+ foreach my $group (@$groups) {
+ $sth_group->execute($bug->bug_id, $group->id);
}
$dbh->do('UPDATE bugs SET creation_ts = ? WHERE bug_id = ?', undef,
}
}
- # And insert the comment. We always insert a comment on bug creation,
+ # Comment #0 handling...
+
+ # We now have a bug id so we can fill this out
+ $creation_comment->{'bug_id'} = $bug->id;
+
+ # Insert the comment. We always insert a comment on bug creation,
# but sometimes it's blank.
- my @columns = qw(bug_id who bug_when thetext);
- my @values = ($bug->bug_id, $bug->{reporter_id}, $timestamp, $comment);
- # We don't include the "isprivate" column unless it was specified.
- # This allows it to fall back to its database default.
- if (defined $privacy) {
- push(@columns, 'isprivate');
- push(@values, $privacy);
- }
- my $qmarks = "?," x @columns;
- chop($qmarks);
- $dbh->do('INSERT INTO longdescs (' . join(',', @columns) . ")
- VALUES ($qmarks)", undef, @values);
+ Bugzilla::Comment->insert_create_data($creation_comment);
+
+ Bugzilla::Hook::process('bug_end_of_create', { bug => $bug,
+ timestamp => $timestamp,
+ });
$dbh->bz_commit_transaction();
return $bug;
}
-
sub run_create_validators {
my $class = shift;
my $params = $class->SUPER::run_create_validators(@_);
- my $product = $params->{product};
+ my $product = delete $params->{product};
$params->{product_id} = $product->id;
- delete $params->{product};
-
- ($params->{bug_status}, $params->{everconfirmed})
- = $class->_check_bug_status($params->{bug_status}, $product,
- $params->{comment});
-
- $params->{target_milestone} = $class->_check_target_milestone(
- $params->{target_milestone}, $product);
-
- $params->{version} = $class->_check_version($params->{version}, $product);
-
- $params->{keywords} = $class->_check_keywords($params->{keywords}, $product);
-
- $params->{groups} = $class->_check_groups($product,
- $params->{groups});
-
- my $component = $class->_check_component($params->{component}, $product);
+ my $component = delete $params->{component};
$params->{component_id} = $component->id;
- delete $params->{component};
- $params->{assigned_to} =
- $class->_check_assigned_to($params->{assigned_to}, $component);
- $params->{qa_contact} =
- $class->_check_qa_contact($params->{qa_contact}, $component);
- $params->{cc} = $class->_check_cc($component, $params->{cc});
-
- # Callers cannot set Reporter, currently.
+ # Callers cannot set reporter, creation_ts, or delta_ts.
$params->{reporter} = $class->_check_reporter();
-
- $params->{creation_ts} ||= Bugzilla->dbh->selectrow_array('SELECT NOW()');
$params->{delta_ts} = $params->{creation_ts};
if ($params->{estimated_time}) {
# You can't set these fields on bug creation (or sometimes ever).
delete $params->{resolution};
- delete $params->{votes};
delete $params->{lastdiffed};
delete $params->{bug_id};
+ Bugzilla::Hook::process('bug_end_of_create_validators',
+ { params => $params });
+
+ my @mandatory_fields = @{ Bugzilla->fields({ is_mandatory => 1,
+ enter_bug => 1,
+ obsolete => 0 }) };
+ foreach my $field (@mandatory_fields) {
+ $class->_check_field_is_mandatory($params->{$field->name}, $field,
+ $params);
+ }
+
return $params;
}
my $dbh = Bugzilla->dbh;
# XXX This is just a temporary hack until all updating happens
# inside this function.
- my $delta_ts = shift || $dbh->selectrow_array("SELECT NOW()");
+ my $delta_ts = shift || $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
+
+ $dbh->bz_start_transaction();
- my $old_bug = $self->new($self->id);
- my $changes = $self->SUPER::update(@_);
+ my ($changes, $old_bug) = $self->SUPER::update(@_);
# Certain items in $changes have to be fixed so that they hold
# a name instead of an ID.
$dbh->do('INSERT INTO keywords (bug_id, keywordid) VALUES (?,?)',
undef, $self->id, $keyword_id);
}
- $dbh->do('UPDATE bugs SET keywords = ? WHERE bug_id = ?', undef,
- $self->keywords, $self->id);
# If any changes were found, record it in the activity log
if (scalar @$removed_kw || scalar @$added_kw) {
my $removed_keywords = Bugzilla::Keyword->new_from_list($removed_kw);
$changes->{'bug_group'} = [join(', ', @removed_names),
join(', ', @added_names)];
}
-
+
+ # Flags
+ my ($removed, $added) = Bugzilla::Flag->update_flags($self, $old_bug, $delta_ts);
+ if ($removed || $added) {
+ $changes->{'flagtypes.name'} = [$removed, $added];
+ }
+
# Comments
foreach my $comment (@{$self->{added_comments} || []}) {
- my $columns = join(',', keys %$comment);
- my @values = values %$comment;
- my $qmarks = join(',', ('?') x @values);
- $dbh->do("INSERT INTO longdescs (bug_id, who, bug_when, $columns)
- VALUES (?,?,?,$qmarks)", undef,
- $self->bug_id, Bugzilla->user->id, $delta_ts, @values);
- if ($comment->{work_time}) {
- LogActivityEntry($self->id, "work_time", "", $comment->{work_time},
+ # Override the Comment's timestamp to be identical to the update
+ # timestamp.
+ $comment->{bug_when} = $delta_ts;
+ $comment = Bugzilla::Comment->insert_create_data($comment);
+ if ($comment->work_time) {
+ LogActivityEntry($self->id, "work_time", "", $comment->work_time,
Bugzilla->user->id, $delta_ts);
}
}
-
- foreach my $comment_id (keys %{$self->{comment_isprivate} || {}}) {
- $dbh->do("UPDATE longdescs SET isprivate = ? WHERE comment_id = ?",
- undef, $self->{comment_isprivate}->{$comment_id}, $comment_id);
- # XXX It'd be nice to track this in the bug activity.
+
+ # Comment Privacy
+ foreach my $comment (@{$self->{comment_isprivate} || []}) {
+ $comment->update();
+
+ my ($from, $to)
+ = $comment->is_private ? (0, 1) : (1, 0);
+ LogActivityEntry($self->id, "longdescs.isprivate", $from, $to,
+ Bugzilla->user->id, $delta_ts, $comment->id);
}
# Insert the values into the multiselect value tables
}
}
+ # See Also
+
+ my ($removed_see, $added_see) =
+ diff_arrays($old_bug->see_also, $self->see_also, 'name');
+
+ $_->remove_from_db foreach @$removed_see;
+ $_->insert_create_data($_) foreach @$added_see;
+
+ # If any changes were found, record it in the activity log
+ if (scalar @$removed_see || scalar @$added_see) {
+ $changes->{see_also} = [join(', ', map { $_->name } @$removed_see),
+ join(', ', map { $_->name } @$added_see)];
+ }
+
+ $_->update foreach @{ $self->{_update_ref_bugs} || [] };
+ delete $self->{_update_ref_bugs};
+
# Log bugs_activity items
# XXX Eventually, when bugs_activity is able to track the dupe_id,
# this code should go below the duplicates-table-updating code below.
$changes->{'dup_id'} = [$old_dup || undef, $cur_dup || undef];
}
- Bugzilla::Hook::process('bug-end_of_update', { bug => $self,
- timestamp => $delta_ts,
- changes => $changes,
- });
+ Bugzilla::Hook::process('bug_end_of_update',
+ { bug => $self, timestamp => $delta_ts, changes => $changes,
+ old_bug => $old_bug });
# If any change occurred, refresh the timestamp of the bug.
- if (scalar(keys %$changes) || $self->{added_comments}) {
+ if (scalar(keys %$changes) || $self->{added_comments}
+ || $self->{comment_isprivate})
+ {
$dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
undef, ($delta_ts, $self->id));
$self->{delta_ts} = $delta_ts;
}
+ $dbh->bz_commit_transaction();
+
# The only problem with this here is that update() is often called
# in the middle of a transaction, and if that transaction is rolled
# back, this change will *not* be rolled back. As we expect rollbacks
# to be extremely rare, that is OK for us.
$self->_sync_fulltext()
- if $self->{added_comments} || $changes->{short_desc};
+ if $self->{added_comments} || $changes->{short_desc}
+ || $self->{comment_isprivate};
# Remove obsolete internal variables.
delete $self->{'_old_assigned_to'};
delete $self->{'_old_qa_contact'};
+ # Also flush the visible_bugs cache for this bug as the user's
+ # relationship with this bug may have changed.
+ delete Bugzilla->user->{_visible_bugs_cache}->{$self->id};
+
return $changes;
}
# - flags
# - keywords
# - longdescs
- # - votes
- # Also included are custom multi-select fields.
# Also, the attach_data table uses attachments.attach_id as a foreign
# key, and so indirectly depends on a bug deletion too.
undef, ($bug_id, $bug_id));
$dbh->do("DELETE FROM flags WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM keywords WHERE bug_id = ?", undef, $bug_id);
- $dbh->do("DELETE FROM votes WHERE bug_id = ?", undef, $bug_id);
# The attach_data table doesn't depend on bugs.bug_id directly.
my $attach_ids =
$dbh->do("DELETE FROM bugs WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM longdescs WHERE bug_id = ?", undef, $bug_id);
- # Delete entries from custom multi-select fields.
- my @multi_selects = Bugzilla->get_fields({custom => 1, type => FIELD_TYPE_MULTI_SELECT});
-
- foreach my $field (@multi_selects) {
- $dbh->do("DELETE FROM bug_" . $field->name . " WHERE bug_id = ?", undef, $bug_id);
- }
-
$dbh->bz_commit_transaction();
# The bugs_fulltext table doesn't support transactions.
$dbh->do("DELETE FROM bugs_fulltext WHERE bug_id = ?", undef, $bug_id);
- # Now this bug no longer exists
- $self->DESTROY;
- return $self;
+ undef $self;
+}
+
+#####################################################################
+# Sending Email After Bug Update
+#####################################################################
+
+sub send_changes {
+ my ($self, $changes, $vars) = @_;
+
+ my $user = Bugzilla->user;
+
+ my $old_qa = $changes->{'qa_contact'}
+ ? $changes->{'qa_contact'}->[0] : '';
+ my $old_own = $changes->{'assigned_to'}
+ &nbs