globals.pl 50.4 KB
Newer Older
1 2
# -*- Mode: perl; indent-tabs-mode: nil -*-
#
3 4 5 6 7 8 9 10 11 12
# The contents of this file are subject to the Mozilla Public
# License Version 1.1 (the "License"); you may not use this file
# except in compliance with the License. You may obtain a copy of
# the License at http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS
# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
# implied. See the License for the specific language governing
# rights and limitations under the License.
#
13
# The Original Code is the Bugzilla Bug Tracking System.
14
#
15
# The Initial Developer of the Original Code is Netscape Communications
16 17 18 19
# Corporation. Portions created by Netscape are
# Copyright (C) 1998 Netscape Communications Corporation. All
# Rights Reserved.
#
20
# Contributor(s): Terry Weissman <terry@mozilla.org>
21
#                 Dan Mosedale <dmose@mozilla.org>
22
#                 Jacob Steenhagen <jake@bugzilla.org>
23
#                 Bradley Baetz <bbaetz@student.usyd.edu.au>
24
#                 Christopher Aillon <christopher@aillon.com>
25
#                 Joel Peshkin <bugreport@peshkin.net> 
26 27 28 29

# Contains some global variables and routines used throughout bugzilla.

use strict;
30

31
use Bugzilla::DB qw(:DEFAULT :deprecated);
32
use Bugzilla::Constants;
33
use Bugzilla::Util;
34
# Bring ChmodDataFile in until this is all moved to the module
35
use Bugzilla::Config qw(:DEFAULT ChmodDataFile $localconfig $datadir);
36

37 38 39 40 41 42
# Shut up misguided -w warnings about "used only once".  For some reason,
# "use vars" chokes on me when I try it here.

sub globals_pl_sillyness {
    my $zz;
    $zz = @main::default_column_list;
43
    $zz = $main::defaultqueryname;
44
    $zz = @main::enterable_products;
45
    $zz = %main::keywordsbyname;
46 47
    $zz = @main::legal_bug_status;
    $zz = @main::legal_components;
48
    $zz = @main::legal_keywords;
49 50 51 52 53 54 55 56
    $zz = @main::legal_opsys;
    $zz = @main::legal_platform;
    $zz = @main::legal_priority;
    $zz = @main::legal_product;
    $zz = @main::legal_severity;
    $zz = @main::legal_target_milestone;
    $zz = @main::legal_versions;
    $zz = @main::milestoneurl;
57
    $zz = %main::proddesc;
58
    $zz = @main::prodmaxvotes;
59
    $zz = $main::template;
60
    $zz = $main::userid;
61
    $zz = $main::vars;
62 63
}

64 65 66 67 68
#
# Here are the --LOCAL-- variables defined in 'localconfig' that we'll use
# here
# 

69 70
# XXX - Move this to Bugzilla::Config once code which uses these has moved out
# of globals.pl
71
do $localconfig;
72

73
use DBI;
74 75

use Date::Format;               # For time2str().
76
use Date::Parse;               # For str2time().
77
use RelationSet;
78

79 80 81
# Use standard Perl libraries for cross-platform file/directory manipulation.
use File::Spec;
    
82
# Some environment variables are not taint safe
83
delete @::ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};
84

85 86 87 88 89
# Cwd.pm in perl 5.6.1 gives a warning if $::ENV{'PATH'} isn't defined
# Set this to '' so that we don't get warnings cluttering the logs on every
# system call
$::ENV{'PATH'} = '';

90 91 92 93 94 95
# Ignore SIGTERM and SIGPIPE - this prevents DB corruption. If the user closes
# their browser window while a script is running, the webserver sends these
# signals, and we don't want to die half way through a write.
$::SIG{TERM} = 'IGNORE';
$::SIG{PIPE} = 'IGNORE';

96
$::defaultqueryname = "(Default query)"; # This string not exposed in UI
97
$::unconfirmedstate = "UNCONFIRMED";
98

99 100 101 102 103 104 105 106 107 108 109
# The following subroutine is for debugging purposes only.
# Uncommenting this sub and the $::SIG{__DIE__} trap underneath it will
# cause any fatal errors to result in a call stack trace to help track
# down weird errors.
#sub die_with_dignity {
#    use Carp;  # for confess()
#    my ($err_msg) = @_;
#    print $err_msg;
#    confess($err_msg);
#}
#$::SIG{__DIE__} = \&die_with_dignity;
110

111 112 113
@::default_column_list = ("bug_severity", "priority", "rep_platform", 
                          "assigned_to", "bug_status", "resolution",
                          "short_short_desc");
114 115

sub AppendComment {
116 117
    my ($bugid, $who, $comment, $isprivate, $timestamp, $work_time) = @_;
    $work_time ||= 0;
118 119 120 121 122
    
    # Use the date/time we were given if possible (allowing calling code
    # to synchronize the comment's timestamp with those of other records).
    $timestamp = ($timestamp ? SqlQuote($timestamp) : "NOW()");
    
123 124
    $comment =~ s/\r\n/\n/g;     # Get rid of windows-style line endings.
    $comment =~ s/\r/\n/g;       # Get rid of mac-style line endings.
125 126 127 128 129 130 131 132 133 134 135

    # allowing negatives though so people can back out errors in time reporting
    if (defined $work_time) {
       # regexp verifies one or more digits, optionally followed by a period and
       # zero or more digits, OR we have a period followed by one or more digits
       if ($work_time !~ /^-?(?:\d+(?:\.\d*)?|\.\d+)$/) { 
          ThrowUserError("need_numeric_value");
       }
    } else { $work_time = 0 };

    if ($comment =~ /^\s*$/) {  # Nothin' but whitespace
136 137
        return;
    }
138 139

    my $whoid = DBNameToIdAndCheck($who);
140
    my $privacyval = $isprivate ? 1 : 0 ;
141
    SendSQL("INSERT INTO longdescs (bug_id, who, bug_when, thetext, isprivate, work_time) " .
142
        "VALUES($bugid, $whoid, $timestamp, " . SqlQuote($comment) . ", " . 
143
        $privacyval . ", " . SqlQuote($work_time) . ")");
144 145

    SendSQL("UPDATE bugs SET delta_ts = now() WHERE bug_id = $bugid");
146 147
}

148 149 150 151
sub GetFieldID {
    my ($f) = (@_);
    SendSQL("SELECT fieldid FROM fielddefs WHERE name = " . SqlQuote($f));
    my $fieldid = FetchOneColumn();
152
    die "Unknown field id: $f" if !$fieldid;
153 154
    return $fieldid;
}
155

156
# XXXX - this needs to go away
157
sub GenerateVersionTable {
158 159 160 161
    SendSQL("SELECT versions.value, products.name " .
            "FROM versions, products " .
            "WHERE products.id = versions.product_id " .
            "ORDER BY versions.value");
162 163 164 165 166 167 168 169 170 171 172
    my @line;
    my %varray;
    my %carray;
    while (@line = FetchSQLData()) {
        my ($v,$p1) = (@line);
        if (!defined $::versions{$p1}) {
            $::versions{$p1} = [];
        }
        push @{$::versions{$p1}}, $v;
        $varray{$v} = 1;
    }
173 174 175 176
    SendSQL("SELECT components.name, products.name " .
            "FROM components, products " .
            "WHERE products.id = components.product_id " .
            "ORDER BY components.name");
177 178 179 180 181 182 183 184 185 186
    while (@line = FetchSQLData()) {
        my ($c,$p) = (@line);
        if (!defined $::components{$p}) {
            $::components{$p} = [];
        }
        my $ref = $::components{$p};
        push @$ref, $c;
        $carray{$c} = 1;
    }

187 188 189 190 191
    my $dotargetmilestone = 1;  # This used to check the param, but there's
                                # enough code that wants to pretend we're using
                                # target milestones, even if they don't get
                                # shown to the user.  So we cache all the data
                                # about them anyway.
192 193

    my $mpart = $dotargetmilestone ? ", milestoneurl" : "";
194
    SendSQL("select name, description, votesperuser, disallownew$mpart from products ORDER BY name");
195
    while (@line = FetchSQLData()) {
196
        my ($p, $d, $votesperuser, $dis, $u) = (@line);
197
        $::proddesc{$p} = $d;
198
        if (!$dis && scalar($::components{$p})) {
199
            push @::enterable_products, $p;
200
        }
201 202 203
        if ($dotargetmilestone) {
            $::milestoneurl{$p} = $u;
        }
204
        $::prodmaxvotes{$p} = $votesperuser;
205 206
    }
            
207 208 209
    my $cols = LearnAboutColumns("bugs");
    
    @::log_columns = @{$cols->{"-list-"}};
210
    foreach my $i ("bug_id", "creation_ts", "delta_ts", "lastdiffed") {
211 212 213 214 215
        my $w = lsearch(\@::log_columns, $i);
        if ($w >= 0) {
            splice(@::log_columns, $w, 1);
        }
    }
216
    @::log_columns = (sort(@::log_columns));
217 218 219 220

    @::legal_priority = SplitEnumType($cols->{"priority,type"});
    @::legal_severity = SplitEnumType($cols->{"bug_severity,type"});
    @::legal_platform = SplitEnumType($cols->{"rep_platform,type"});
221
    @::legal_opsys = SplitEnumType($cols->{"op_sys,type"});
222 223
    @::legal_bug_status = SplitEnumType($cols->{"bug_status,type"});
    @::legal_resolution = SplitEnumType($cols->{"resolution,type"});
224 225 226 227 228 229 230 231

    # 'settable_resolution' is the list of resolutions that may be set 
    # directly by hand in the bug form. Start with the list of legal 
    # resolutions and remove 'MOVED' and 'DUPLICATE' because setting 
    # bugs to those resolutions requires a special process.
    #
    @::settable_resolution = @::legal_resolution;
    my $w = lsearch(\@::settable_resolution, "DUPLICATE");
232
    if ($w >= 0) {
233 234 235 236 237
        splice(@::settable_resolution, $w, 1);
    }
    my $z = lsearch(\@::settable_resolution, "MOVED");
    if ($z >= 0) {
        splice(@::settable_resolution, $z, 1);
238 239 240 241 242
    }

    my @list = sort { uc($a) cmp uc($b)} keys(%::versions);
    @::legal_product = @list;

243 244
    require File::Temp;
    my ($fh, $tmpname) = File::Temp::tempfile("versioncache.XXXXX",
245
                                              DIR => "$datadir");
246 247 248 249 250 251 252

    print $fh "#\n";
    print $fh "# DO NOT EDIT!\n";
    print $fh "# This file is automatically generated at least once every\n";
    print $fh "# hour by the GenerateVersionTable() sub in globals.pl.\n";
    print $fh "# Any changes you make will be overwritten.\n";
    print $fh "#\n";
253

254
    require Data::Dumper;
255 256
    print $fh (Data::Dumper->Dump([\@::log_columns, \%::versions],
                                  ['*::log_columns', '*::versions']));
257 258 259

    foreach my $i (@list) {
        if (!defined $::components{$i}) {
260
            $::components{$i} = [];
261 262 263
        }
    }
    @::legal_versions = sort {uc($a) cmp uc($b)} keys(%varray);
264 265
    print $fh (Data::Dumper->Dump([\@::legal_versions, \%::components],
                                  ['*::legal_versions', '*::components']));
266
    @::legal_components = sort {uc($a) cmp uc($b)} keys(%carray);
267

268 269 270 271 272 273 274 275
    print $fh (Data::Dumper->Dump([\@::legal_components, \@::legal_product,
                                   \@::legal_priority, \@::legal_severity,
                                   \@::legal_platform, \@::legal_opsys,
                                   \@::legal_bug_status, \@::legal_resolution],
                                  ['*::legal_components', '*::legal_product',
                                   '*::legal_priority', '*::legal_severity',
                                   '*::legal_platform', '*::legal_opsys',
                                   '*::legal_bug_status', '*::legal_resolution']));
276

277 278 279 280
    print $fh (Data::Dumper->Dump([\@::settable_resolution, \%::proddesc,
                                   \@::enterable_products, \%::prodmaxvotes],
                                  ['*::settable_resolution', '*::proddesc',
                                   '*::enterable_products', '*::prodmaxvotes']));
281

282
    if ($dotargetmilestone) {
283
        # reading target milestones in from the database - matthew@zeroknowledge.com
284 285 286 287
        SendSQL("SELECT milestones.value, products.name " .
                "FROM milestones, products " .
                "WHERE products.id = milestones.product_id " .
                "ORDER BY milestones.sortkey, milestones.value");
288 289 290 291 292 293 294 295 296 297 298 299 300
        my @line;
        my %tmarray;
        @::legal_target_milestone = ();
        while(@line = FetchSQLData()) {
            my ($tm, $pr) = (@line);
            if (!defined $::target_milestone{$pr}) {
                $::target_milestone{$pr} = [];
            }
            push @{$::target_milestone{$pr}}, $tm;
            if (!exists $tmarray{$tm}) {
                $tmarray{$tm} = 1;
                push(@::legal_target_milestone, $tm);
            }
301
        }
302

303 304 305 306 307 308
        print $fh (Data::Dumper->Dump([\%::target_milestone,
                                       \@::legal_target_milestone,
                                       \%::milestoneurl],
                                      ['*::target_milestone',
                                       '*::legal_target_milestone',
                                       '*::milestoneurl']));
309
    }
310 311 312 313

    SendSQL("SELECT id, name FROM keyworddefs ORDER BY name");
    while (MoreSQLData()) {
        my ($id, $name) = FetchSQLData();
314
        push(@::legal_keywords, $name);
315
        $name = lc($name);
316 317
        $::keywordsbyname{$name} = $id;
    }
318

319 320
    print $fh (Data::Dumper->Dump([\@::legal_keywords, \%::keywordsbyname],
                                  ['*::legal_keywords', '*::keywordsbyname']));
321

322 323
    print $fh "1;\n";
    close $fh;
324

325 326
    rename $tmpname, "$datadir/versioncache" || die "Can't rename $tmpname to versioncache";
    ChmodDataFile("$datadir/versioncache", 0666);
327 328 329
}


330 331 332 333 334 335 336 337
sub GetKeywordIdFromName {
    my ($name) = (@_);
    $name = lc($name);
    return $::keywordsbyname{$name};
}



338 339 340 341 342 343 344 345 346 347 348

# Returns the modification time of a file.

sub ModTime {
    my ($filename) = (@_);
    my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,
        $atime,$mtime,$ctime,$blksize,$blocks)
        = stat($filename);
    return $mtime;
}

349
$::VersionTableLoaded = 0;
350
sub GetVersionTable {
351
    return if $::VersionTableLoaded;
352 353
    my $mtime = ModTime("$datadir/versioncache");
    if (!defined $mtime || $mtime eq "" || !-r "$datadir/versioncache") {
354 355 356
        $mtime = 0;
    }
    if (time() - $mtime > 3600) {
357
        use Token;
358
        Token::CleanTokenTable() if Bugzilla->dbwritesallowed;
359 360
        GenerateVersionTable();
    }
361
    require "$datadir/versioncache";
362 363
    if (!defined %::versions) {
        GenerateVersionTable();
364
        do "$datadir/versioncache";
365 366

        if (!defined %::versions) {
367
            die "Can't generate file $datadir/versioncache";
368 369
        }
    }
370
    $::VersionTableLoaded = 1;
371 372
}

373 374 375 376 377 378 379 380 381
# Validates a given username as a new username
# returns 1 if valid, 0 if invalid
sub ValidateNewUser {
    my ($username, $old_username) = @_;

    if(DBname_to_id($username) != 0) {
        return 0;
    }

382 383
    my $sqluname = SqlQuote($username);

384 385
    # Reject if the new login is part of an email change which is 
    # still in progress
386 387 388 389 390
    #
    # substring/locate stuff: bug 165221; this used to use regexes, but that
    # was unsafe and required weird escaping; using substring to pull out
    # the new/old email addresses and locate() to find the delimeter (':')
    # is cleaner/safer
391
    SendSQL("SELECT eventdata FROM tokens WHERE tokentype = 'emailold' 
392 393 394
     AND SUBSTRING(eventdata, 1, (LOCATE(':', eventdata) - 1)) = $sqluname 
     OR SUBSTRING(eventdata, (LOCATE(':', eventdata) + 1)) = $sqluname");

395 396 397 398 399 400 401 402 403 404 405
    if (my ($eventdata) = FetchSQLData()) {
        # Allow thru owner of token
        if($old_username && ($eventdata eq "$old_username:$username")) {
            return 1;
        }
        return 0;
    }

    return 1;
}

406
sub InsertNewUser {
407
    my ($username, $realname) = (@_);
408

409 410 411 412 413 414
    # Generate a new random password for the user.
    my $password = GenerateRandomPassword();
    my $cryptpassword = Crypt($password);


    # Insert the new user record into the database.            
415 416
    $username = SqlQuote($username);
    $realname = SqlQuote($realname);
417
    $cryptpassword = SqlQuote($cryptpassword);
418 419 420
    PushGlobalSQLState();
    SendSQL("INSERT INTO profiles (login_name, realname, cryptpassword) 
             VALUES ($username, $realname, $cryptpassword)");
421
    PopGlobalSQLState();
422 423 424 425 426 427

    # Return the password to the calling code so it can be included 
    # in an email sent to the user.
    return $password;
}

428 429 430 431 432 433 434 435 436 437 438 439 440
# Removes all entries from logincookies for $userid, except for the
# optional $keep, which refers the logincookies.cookie primary key.
# (This is useful so that a user changing their password stays logged in)
sub InvalidateLogins {
    my ($userid, $keep) = @_;

    my $remove = "DELETE FROM logincookies WHERE userid = $userid";
    if (defined $keep) {
        $remove .= " AND cookie != " . SqlQuote($keep);
    }
    SendSQL($remove);
}

441 442 443 444 445 446
sub GenerateRandomPassword {
    my ($size) = @_;

    # Generated passwords are eight characters long by default.
    $size ||= 8;

447
    # The list of characters that can appear in a randomly generated password.
448 449 450 451
    # Note that users can put any character into a password they choose
    # themselves.
    my @pwchars = (0..9, 'A'..'Z', 'a'..'z', '-', '_', '!', '@', '#', '$',
        '%', '^', '*');
452 453 454 455 456 457 458 459 460 461 462

    # The number of characters in the list.
    my $pwcharslen = scalar(@pwchars);

    # Generate the password.
    my $password = "";
    for ( my $i=0 ; $i<$size ; $i++ ) {
        $password .= $pwchars[rand($pwcharslen)];
    }

    # Return the password.
463 464 465
    return $password;
}

466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481
#
# This function checks if there are any entry groups defined.
# If called with no arguments, it identifies
# entry groups for all products.  If called with a product
# id argument, it checks for entry groups associated with 
# one particular product.
sub AnyEntryGroups {
    my $product_id = shift;
    $product_id = 0 unless ($product_id);
    return $::CachedAnyEntryGroups{$product_id} 
        if defined($::CachedAnyEntryGroups{$product_id});
    PushGlobalSQLState();
    my $query = "SELECT 1 FROM group_control_map WHERE entry != 0";
    $query .= " AND product_id = $product_id" if ($product_id);
    $query .= " LIMIT 1";
    SendSQL($query);
482 483 484 485 486 487 488 489
    if (MoreSQLData()) {
       $::CachedAnyEntryGroups{$product_id} = MoreSQLData();
       FetchSQLData();
       PopGlobalSQLState();
       return $::CachedAnyEntryGroups{$product_id};
    } else {
       return undef;
    }
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517
}
#
# This function checks if there are any default groups defined.
# If so, then groups may have to be changed when bugs move from
# one bug to another.
sub AnyDefaultGroups {
    return $::CachedAnyDefaultGroups if defined($::CachedAnyDefaultGroups);
    PushGlobalSQLState();
    SendSQL("SELECT 1 FROM group_control_map, groups WHERE " .
            "groups.id = group_control_map.group_id " .
            "AND isactive != 0 AND " .
            "(membercontrol = " . CONTROLMAPDEFAULT .
            " OR othercontrol = " . CONTROLMAPDEFAULT .
            ") LIMIT 1");
    $::CachedAnyDefaultGroups = MoreSQLData();
    FetchSQLData();
    PopGlobalSQLState();
    return $::CachedAnyDefaultGroups;
}

#
# This function checks if, given a product id, the user can edit
# bugs in this product at all.
sub CanEditProductId {
    my ($productid) = @_;
    my $query = "SELECT group_id FROM group_control_map " .
                "WHERE product_id = $productid " .
                "AND canedit != 0 "; 
518
    if (defined Bugzilla->user && %{Bugzilla->user->groups}) {
519
        $query .= "AND group_id NOT IN(" . 
520
                   join(',', values(%{Bugzilla->user->groups})) . ") ";
521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539
    }
    $query .= "LIMIT 1";
    PushGlobalSQLState();
    SendSQL($query);
    my ($result) = FetchSQLData();
    PopGlobalSQLState();
    return (!defined($result));
}

#
# This function determines if a user can enter bugs in the named
# product.
sub CanEnterProduct {
    my ($productname) = @_;
    my $query = "SELECT group_id IS NULL " .
                "FROM products " .
                "LEFT JOIN group_control_map " .
                "ON group_control_map.product_id = products.id " .
                "AND group_control_map.entry != 0 ";
540
    if (defined Bugzilla->user && %{Bugzilla->user->groups}) {
541
        $query .= "AND group_id NOT IN(" . 
542
                   join(',', values(%{Bugzilla->user->groups})) . ") ";
543 544 545 546 547 548 549 550 551 552 553
    }
    $query .= "WHERE products.name = " . SqlQuote($productname) . " LIMIT 1";
    PushGlobalSQLState();
    SendSQL($query);
    my ($ret) = FetchSQLData();
    PopGlobalSQLState();
    return ($ret);
}

#
# This function returns an alphabetical list of product names to which
554 555 556
# the user can enter bugs.  If the $by_id parameter is true, also retrieves IDs
# and pushes them onto the list as id, name [, id, name...] for easy slurping
# into a hash by the calling code.
557
sub GetSelectableProducts {
558 559 560 561 562
    my ($by_id) = @_;

    my $extra_sql = $by_id ? "id, " : "";

    my $query = "SELECT $extra_sql name " .
563 564
                "FROM products " .
                "LEFT JOIN group_control_map " .
565 566 567 568 569 570 571
                "ON group_control_map.product_id = products.id ";
    if (Param('useentrygroupdefault')) {
        $query .= "AND group_control_map.entry != 0 ";
    } else {
        $query .= "AND group_control_map.membercontrol = " .
                  CONTROLMAPMANDATORY . " ";
    }
572
    if (defined Bugzilla->user && %{Bugzilla->user->groups}) {
573
        $query .= "AND group_id NOT IN(" . 
574
                   join(',', values(%{Bugzilla->user->groups})) . ") ";
575 576 577 578 579
    }
    $query .= "WHERE group_id IS NULL ORDER BY name";
    PushGlobalSQLState();
    SendSQL($query);
    my @products = ();
580
    push(@products, FetchSQLData()) while MoreSQLData();
581 582 583 584
    PopGlobalSQLState();
    return (@products);
}

585
# GetSelectableProductHash
586 587
# returns a hash containing 
# legal_products => an enterable product list
588 589 590 591 592
# legal_(components|versions|milestones) =>
#   the list of components, versions, and milestones of enterable products
# (components|versions|milestones)_by_product
#    => a hash of component lists for each enterable product
# Milestones only get returned if the usetargetmilestones parameter is set.
593
sub GetSelectableProductHash {
594 595 596 597 598 599 600 601 602 603 604 605 606
    # The hash of selectable products and their attributes that gets returned
    # at the end of this function.
    my $selectables = {};

    my %products = GetSelectableProducts(1);

    $selectables->{legal_products} = [sort values %products];

    # Run queries that retrieve the list of components, versions,
    # and target milestones (if used) for the selectable products.
    my @tables = qw(components versions);
    push(@tables, 'milestones') if Param('usetargetmilestone');

607
    PushGlobalSQLState();
608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623
    foreach my $table (@tables) {
        # Why oh why can't we standardize on these names?!?
        my $fld = ($table eq "components" ? "name" : "value");

        my $query = "SELECT $fld, product_id FROM $table WHERE product_id IN " .
                    "(" . join(",", keys %products) . ") ORDER BY $fld";
        SendSQL($query);

        my %values;
        my %values_by_product;

        while (MoreSQLData()) {
            my ($name, $product_id) = FetchSQLData();
            next unless $name;
            $values{$name} = 1;
            push @{$values_by_product{$products{$product_id}}}, $name;
624
        }
625 626 627

        $selectables->{"legal_$table"} = [sort keys %values];
        $selectables->{"${table}_by_product"} = \%values_by_product;
628 629
    }
    PopGlobalSQLState();
630 631

    return $selectables;
632 633 634
}


635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654
sub GetFieldDefs {
    my $extra = "";
    if (!UserInGroup(Param('timetrackinggroup'))) {
        $extra = "WHERE name NOT IN ('estimated time', 'remaining_time', " .
                 "'work_time', 'percentage_complete')";
    }

    my @fields;
    PushGlobalSQLState();
    SendSQL("SELECT name, description FROM fielddefs $extra ORDER BY sortkey");
    while (MoreSQLData()) {
        my ($name, $description) = FetchSQLData();
        push(@fields, { name => $name, description => $description });
    }
    PopGlobalSQLState();

    return(@fields);
}


655 656
sub CanSeeBug {

657
    my ($id, $userid) = @_;
658 659 660 661

    # Query the database for the bug, retrieving a boolean value that
    # represents whether or not the user is authorized to access the bug.

662 663 664 665 666
    # if no groups are found --> user is permitted to access
    # if no user is found for any group --> user is not permitted to access
    my $query = "SELECT bugs.bug_id, reporter, assigned_to, qa_contact," .
        " reporter_accessible, cclist_accessible," .
        " cc.who IS NOT NULL," .
667
        " COUNT(DISTINCT(bug_group_map.group_id)) as cntbugingroups," .
668 669 670 671 672 673 674 675 676 677
        " COUNT(DISTINCT(user_group_map.group_id)) as cntuseringroups" .
        " FROM bugs" .
        " LEFT JOIN cc ON bugs.bug_id = cc.bug_id" .
        " AND cc.who = $userid" .
        " LEFT JOIN bug_group_map ON bugs.bug_id = bug_group_map.bug_id" .
        " LEFT JOIN user_group_map ON" .
        " user_group_map.group_id = bug_group_map.group_id" .
        " AND user_group_map.isbless = 0" .
        " AND user_group_map.user_id = $userid" .
        " WHERE bugs.bug_id = $id GROUP BY bugs.bug_id";
678
    PushGlobalSQLState();
679 680 681 682 683
    SendSQL($query);
    my ($found_id, $reporter, $assigned_to, $qa_contact,
        $rep_access, $cc_access,
        $found_cc, $found_groups, $found_members) 
        = FetchSQLData();
684
    PopGlobalSQLState();
685 686 687 688 689
    return (
               ($found_groups == 0) 
               || (($userid > 0) && 
                  (
                       ($assigned_to == $userid) 
690
                    || (Param('useqacontact') && $qa_contact == $userid)
691 692 693 694 695
                    || (($reporter == $userid) && $rep_access) 
                    || ($found_cc && $cc_access) 
                    || ($found_groups == $found_members)
                  ))
           );
696
}
697 698 699

sub ValidatePassword {
    # Determines whether or not a password is valid (i.e. meets Bugzilla's
700
    # requirements for length and content).    
701 702 703 704
    # If a second password is passed in, this function also verifies that
    # the two passwords match.
    my ($password, $matchpassword) = @_;
    
705 706 707 708 709 710
    if (length($password) < 3) {
        ThrowUserError("password_too_short");
    } elsif (length($password) > 16) {
        ThrowUserError("password_too_long");
    } elsif ($matchpassword && $password ne $matchpassword) { 
        ThrowUserError("passwords_dont_match");
711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746
    }
}


sub Crypt {
    # Crypts a password, generating a random salt to do it.
    # Random salts are generated because the alternative is usually
    # to use the first two characters of the password itself, and since
    # the salt appears in plaintext at the beginning of the crypted
    # password string this has the effect of revealing the first two
    # characters of the password to anyone who views the crypted version.

    my ($password) = @_;

    # The list of characters that can appear in a salt.  Salts and hashes
    # are both encoded as a sequence of characters from a set containing
    # 64 characters, each one of which represents 6 bits of the salt/hash.
    # The encoding is similar to BASE64, the difference being that the
    # BASE64 plus sign (+) is replaced with a forward slash (/).
    my @saltchars = (0..9, 'A'..'Z', 'a'..'z', '.', '/');

    # Generate the salt.  We use an 8 character (48 bit) salt for maximum
    # security on systems whose crypt uses MD5.  Systems with older
    # versions of crypt will just use the first two characters of the salt.
    my $salt = '';
    for ( my $i=0 ; $i < 8 ; ++$i ) {
        $salt .= $saltchars[rand(64)];
    }

    # Crypt the password.
    my $cryptedpassword = crypt($password, $salt);

    # Return the crypted password.
    return $cryptedpassword;
}

747
sub DBID_to_real_or_loginname {
748
    my ($id) = (@_);
749
    PushGlobalSQLState();
750 751
    SendSQL("SELECT login_name,realname FROM profiles WHERE userid = $id");
    my ($l, $r) = FetchSQLData();
752
    PopGlobalSQLState();
753
    if (!defined $r || $r eq "") {
754
        return $l;
755
    } else {
756
        return "$l ($r)";
757 758
    }
}
759 760 761

sub DBID_to_name {
    my ($id) = (@_);
762 763 764 765 766 767
    # $id should always be a positive integer
    if ($id =~ m/^([1-9][0-9]*)$/) {
        $id = $1;
    } else {
        $::cachedNameArray{$id} = "__UNKNOWN__";
    }
768
    if (!defined $::cachedNameArray{$id}) {
769
        PushGlobalSQLState();
770 771
        SendSQL("select login_name from profiles where userid = $id");
        my $r = FetchOneColumn();
772
        PopGlobalSQLState();
773
        if (!defined $r || $r eq "") {
774 775 776 777 778 779 780 781 782
            $r = "__UNKNOWN__";
        }
        $::cachedNameArray{$id} = $r;
    }
    return $::cachedNameArray{$id};
}

sub DBname_to_id {
    my ($name) = (@_);
783
    PushGlobalSQLState();
784 785
    SendSQL("select userid from profiles where login_name = @{[SqlQuote($name)]}");
    my $r = FetchOneColumn();
786
    PopGlobalSQLState();
787 788 789 790
    # $r should be a positive integer, this makes Taint mode happy
    if (defined $r && $r =~ m/^([1-9][0-9]*)$/) {
        return $1;
    } else {
791 792 793 794 795 796
        return 0;
    }
}


sub DBNameToIdAndCheck {
797
    my ($name) = (@_);
798 799 800 801
    my $result = DBname_to_id($name);
    if ($result > 0) {
        return $result;
    }
802

803 804
    ThrowUserError("invalid_username",
                   { name => $name });
805 806
}

807 808 809



810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831
sub get_product_id {
    my ($prod) = @_;
    PushGlobalSQLState();
    SendSQL("SELECT id FROM products WHERE name = " . SqlQuote($prod));
    my ($prod_id) = FetchSQLData();
    PopGlobalSQLState();
    return $prod_id;
}

sub get_product_name {
    my ($prod_id) = @_;
    die "non-numeric prod_id '$prod_id' passed to get_product_name"
      unless ($prod_id =~ /^\d+$/);
    PushGlobalSQLState();
    SendSQL("SELECT name FROM products WHERE id = $prod_id");
    my ($prod) = FetchSQLData();
    PopGlobalSQLState();
    return $prod;
}

sub get_component_id {
    my ($prod_id, $comp) = @_;
832
    return undef unless ($prod_id =~ /^\d+$/);
833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851
    PushGlobalSQLState();
    SendSQL("SELECT id FROM components " .
            "WHERE product_id = $prod_id AND name = " . SqlQuote($comp));
    my ($comp_id) = FetchSQLData();
    PopGlobalSQLState();
    return $comp_id;
}

sub get_component_name {
    my ($comp_id) = @_;
    die "non-numeric comp_id '$comp_id' passed to get_component_name"
      unless ($comp_id =~ /^\d+$/);
    PushGlobalSQLState();
    SendSQL("SELECT name FROM components WHERE id = $comp_id");
    my ($comp) = FetchSQLData();
    PopGlobalSQLState();
    return $comp;
}

852 853 854 855
# This routine quoteUrls contains inspirations from the HTML::FromText CPAN
# module by Gareth Rees <garethr@cre.canon.co.uk>.  It has been heavily hacked,
# all that is really recognizable from the original is bits of the regular
# expressions.
856 857
# This has been rewritten to be faster, mainly by substituting 'as we go'.
# If you want to modify this routine, read the comments carefully
858 859

sub quoteUrls {
860
    my ($text) = (@_);
861 862
    return $text unless $text;

863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884
    # We use /g for speed, but uris can have other things inside them
    # (http://foo/bug#3 for example). Filtering that out filters valid
    # bug refs out, so we have to do replacements.
    # mailto can't contain space or #, so we don't have to bother for that
    # Do this by escaping \0 to \1\0, and replacing matches with \0\0$count\0\0
    # \0 is used because its unliklely to occur in the text, so the cost of
    # doing this should be very small
    # Also, \0 won't appear in the value_quote'd bug title, so we don't have
    # to worry about bogus substitutions from there

    # escape the 2nd escape char we're using
    my $chr1 = chr(1);
    $text =~ s/\0/$chr1\0/g;

    # However, note that adding the title (for buglinks) can affect things
    # In particular, attachment matches go before bug titles, so that titles
    # with 'attachment 1' don't double match.
    # Dupe checks go afterwards, because that uses ^ and \Z, which won't occur
    # if it was subsituted as a bug title (since that always involve leading
    # and trailing text)

    # Because of entities, its easier (and quicker) to do this before escaping
885 886

    my @things;
887 888 889 890
    my $count = 0;
    my $tmp;

    # non-mailto protocols
891
    my $protocol_re = qr/(afs|cid|ftp|gopher|http|https|irc|mid|news|nntp|prospero|telnet|view-source|wais)/i;
892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912

    $text =~ s~\b(${protocol_re}:  # The protocol:
                  [^\s<>\"]+       # Any non-whitespace
                  [\w\/])          # so that we end in \w or /
              ~($tmp = html_quote($1)) &&
               ($things[$count++] = "<a href=\"$tmp\">$tmp</a>") &&
               ("\0\0" . ($count-1) . "\0\0")
              ~egox;

    # We have to quote now, otherwise our html is itsself escaped
    # THIS MEANS THAT A LITERAL ", <, >, ' MUST BE ESCAPED FOR A MATCH

    $text = html_quote($text);

    # mailto:
    # Use |<nothing> so that $1 is defined regardless
    $text =~ s~\b(mailto:|)?([\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+)\b
              ~<a href=\"mailto:$2\">$1$2</a>~igx;

    # attachment links - handle both cases separatly for simplicity
    $text =~ s~((?:^Created\ an\ |\b)attachment\s*\(id=(\d+)\))
913 914
              ~GetAttachmentLink($2, $1)
              ~egmx;
915 916

    $text =~ s~\b(attachment\s*\#?\s*(\d+))
917 918
              ~GetAttachmentLink($2, $1)
              ~egmx;
919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942

    # This handles bug a, comment b type stuff. Because we're using /g
    # we have to do this in one pattern, and so this is semi-messy.
    # Also, we can't use $bug_re?$comment_re? because that will match the
    # empty string
    my $bug_re = qr/bug\s*\#?\s*(\d+)/i;
    my $comment_re = qr/comment\s*\#?\s*(\d+)/i;
    $text =~ s~\b($bug_re(?:\s*,?\s*$comment_re)?|$comment_re)
              ~ # We have several choices. $1 here is the link, and $2-4 are set
                # depending on which part matched
               (defined($2) ? GetBugLink($2,$1,$3) :
                              "<a href=\"#c$4\">$1</a>")
              ~egox;

    # Duplicate markers
    $text =~ s~(?<=^\*\*\*\ This\ bug\ has\ been\ marked\ as\ a\ duplicate\ of\ )
               (\d+)
               (?=\ \*\*\*\Z)
              ~GetBugLink($1, $1)
              ~egmx;

    # Now remove the encoding hacks
    $text =~ s/\0\0(\d+)\0\0/$things[$1]/eg;
    $text =~ s/$chr1\0/\0/g;
943

944 945 946
    return $text;
}

947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000
# GetAttachmentLink creates a link to an attachment,
# including its title.

sub GetAttachmentLink {
    my ($attachid, $link_text) = @_;
    detaint_natural($attachid) ||
        die "GetAttachmentLink() called with non-integer attachment number";

    # If we've run GetAttachmentLink() for this attachment before,
    # %::attachlink will contain an anonymous array ref of relevant
    # values.  If not, we need to get the information from the database.
    if (! defined $::attachlink{$attachid}) {
        # Make sure any unfetched data from a currently running query
        # is saved off rather than overwritten
        PushGlobalSQLState();

        SendSQL("SELECT bug_id, isobsolete, description 
                 FROM attachments WHERE attach_id = $attachid");

        if (MoreSQLData()) {
            my ($bugid, $isobsolete, $desc) = FetchSQLData();
            my $title = "";
            my $className = "";
            if (CanSeeBug($bugid, $::userid)) {
                $title = $desc;
            }
            if ($isobsolete) {
                $className = "bz_obsolete";
            }
            $::attachlink{$attachid} = [value_quote($title), $className];
        }
        else {
            # Even if there's nothing in the database, we want to save a blank
            # anonymous array in the %::attachlink hash so the query doesn't get
            # run again next time we're called for this attachment number.
            $::attachlink{$attachid} = [];
        }
        # All done with this sidetrip
        PopGlobalSQLState();
    }

    # Now that we know we've got all the information we're gonna get, let's
    # return the link (which is the whole reason we were called :)
    my ($title, $className) = @{$::attachlink{$attachid}};
    # $title will be undefined if the bug didn't exist in the database.
    if (defined $title) {
        my $linkval = "attachment.cgi?id=$attachid&amp;action=view";
        return qq{<a href="$linkval" class="$className" title="$title">$link_text</a>};
    }
    else {
        return qq{$link_text};
    }
}

1001
# GetBugLink creates a link to a bug, including its title.
matty%chariot.net.au's avatar
matty%chariot.net.au committed
1002
# It takes either two or three parameters:
1003 1004 1005 1006
#  - The bug number
#  - The link text, to place between the <a>..</a>
#  - An optional comment number, for linking to a particular
#    comment in the bug
1007 1008

sub GetBugLink {
1009
    my ($bug_num, $link_text, $comment_num) = @_;
1010 1011 1012 1013 1014 1015 1016 1017 1018
    detaint_natural($bug_num) || die "GetBugLink() called with non-integer bug number";

    # If we've run GetBugLink() for this bug number before, %::buglink
    # will contain an anonymous array ref of relevent values, if not
    # we need to get the information from the database.
    if (! defined $::buglink{$bug_num}) {
        # Make sure any unfetched data from a currently running query
        # is saved off rather than overwritten
        PushGlobalSQLState();
1019

1020
        SendSQL("SELECT bugs.bug_status, resolution, short_desc " .
1021 1022 1023 1024
                "FROM bugs WHERE bugs.bug_id = $bug_num");

        # If the bug exists, save its data off for use later in the sub
        if (MoreSQLData()) {
1025
            my ($bug_state, $bug_res, $bug_desc) = FetchSQLData();
1026 1027 1028
            # Initialize these variables to be "" so that we don't get warnings
            # if we don't change them below (which is highly likely).
            my ($pre, $title, $post) = ("", "", "");
1029

1030 1031 1032 1033 1034 1035 1036 1037 1038 1039
            $title = $bug_state;
            if ($bug_state eq $::unconfirmedstate) {
                $pre = "<i>";
                $post = "</i>";
            }
            elsif (! IsOpenedState($bug_state)) {
                $pre = "<strike>";
                $title .= " $bug_res";
                $post = "</strike>";
            }
1040
            if (CanSeeBug($bug_num, $::userid)) {
1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053
                $title .= " - $bug_desc";
            }
            $::buglink{$bug_num} = [$pre, value_quote($title), $post];
        }
        else {
            # Even if there's nothing in the database, we want to save a blank
            # anonymous array in the %::buglink hash so the query doesn't get
            # run again next time we're called for this bug number.
            $::buglink{$bug_num} = [];
        }
        # All done with this sidetrip
        PopGlobalSQLState();
    }
1054

1055 1056 1057 1058 1059
    # Now that we know we've got all the information we're gonna get, let's
    # return the link (which is the whole reason we were called :)
    my ($pre, $title, $post) = @{$::buglink{$bug_num}};
    # $title will be undefined if the bug didn't exist in the database.
    if (defined $title) {
1060 1061 1062 1063 1064
        my $linkval = "show_bug.cgi?id=$bug_num";
        if (defined $comment_num) {
            $linkval .= "#c$comment_num";
        }
        return qq{$pre<a href="$linkval" title="$title">$link_text</a>$post};
1065 1066 1067 1068
    }
    else {
        return qq{$link_text};
    }
1069 1070 1071
}

sub GetLongDescriptionAsText {
1072
    my ($id, $start, $end) = (@_);
1073 1074
    my $result = "";
    my $count = 0;
1075
    my $anyprivate = 0;
1076
    my ($query) = ("SELECT profiles.login_name, DATE_FORMAT(longdescs.bug_when,'%Y.%m.%d %H:%i'), " .
1077
                   "       longdescs.thetext, longdescs.isprivate " .
1078 1079 1080
                   "FROM   longdescs, profiles " .
                   "WHERE  profiles.userid = longdescs.who " .
                   "AND    longdescs.bug_id = $id ");
1081 1082 1083

    if ($start && $start =~ /[1-9]/) {
        # If the start is all zeros, then don't do this (because we want to
1084
        # not emit a leading "Additional Comments" line in that case.)
1085 1086 1087 1088 1089 1090 1091 1092 1093
        $query .= "AND longdescs.bug_when > '$start'";
        $count = 1;
    }
    if ($end) {
        $query .= "AND longdescs.bug_when <= '$end'";
    }

    $query .= "ORDER BY longdescs.bug_when";
    SendSQL($query);
1094
    while (MoreSQLData()) {
1095
        my ($who, $when, $text, $isprivate, $work_time) = (FetchSQLData());
1096
        if ($count) {
1097
            $result .= "\n\n------- Additional Comments From $who".Param('emailsuffix')."  ".
1098
                Bugzilla::Util::format_time($when) . " -------\n";
1099
        }
1100 1101 1102
        if (($isprivate > 0) && Param("insidergroup")) {
            $anyprivate = 1;
        }
1103 1104 1105 1106
        $result .= $text;
        $count++;
    }

1107
    return ($result, $anyprivate);
1108 1109
}

1110 1111 1112 1113
sub GetComments {
    my ($id) = (@_);
    my @comments;
    SendSQL("SELECT  profiles.realname, profiles.login_name, 
1114
                     date_format(longdescs.bug_when,'%Y.%m.%d %H:%i'), 
1115
                     longdescs.thetext, longdescs.work_time,
1116 1117
                     isprivate,
                     date_format(longdescs.bug_when,'%Y%m%d%H%i%s') 
1118 1119 1120 1121 1122 1123 1124
            FROM     longdescs, profiles
            WHERE    profiles.userid = longdescs.who 
              AND    longdescs.bug_id = $id 
            ORDER BY longdescs.bug_when");
             
    while (MoreSQLData()) {
        my %comment;
1125 1126
        ($comment{'name'}, $comment{'email'}, $comment{'time'}, 
        $comment{'body'}, $comment{'work_time'},
1127
        $comment{'isprivate'}, $comment{'when'}) = FetchSQLData();
1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138
        
        $comment{'email'} .= Param('emailsuffix');
        $comment{'name'} = $comment{'name'} || $comment{'email'};
         
        push (@comments, \%comment);
    }
    
    return \@comments;
}


1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171
# Fills in a hashtable with info about the columns for the given table in the
# database.  The hashtable has the following entries:
#   -list-  the list of column names
#   <name>,type  the type for the given name

sub LearnAboutColumns {
    my ($table) = (@_);
    my %a;
    SendSQL("show columns from $table");
    my @list = ();
    my @row;
    while (@row = FetchSQLData()) {
        my ($name,$type) = (@row);
        $a{"$name,type"} = $type;
        push @list, $name;
    }
    $a{"-list-"} = \@list;
    return \%a;
}



# If the above returned a enum type, take that type and parse it into the
# list of values.  Assumes that enums don't ever contain an apostrophe!

sub SplitEnumType {
    my ($str) = (@_);
    my @result = ();
    if ($str =~ /^enum\((.*)\)$/) {
        my $guts = $1 . ",";
        while ($guts =~ /^\'([^\']*)\',(.*)$/) {
            push @result, $1;
            $guts = $2;
1172
        }
1173 1174 1175 1176
    }
    return @result;
}

1177
sub UserInGroup {
1178
    return defined Bugzilla->user && defined Bugzilla->user->groups->{$_[0]};
1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210
}

sub UserCanBlessGroup {
    my ($groupname) = (@_);
    PushGlobalSQLState();
    # check if user explicitly can bless group
    SendSQL("SELECT groups.id FROM groups, user_group_map 
        WHERE groups.id = user_group_map.group_id 
        AND user_group_map.user_id = $::userid
        AND isbless = 1
        AND groups.name = " . SqlQuote($groupname));
    my $result = FetchOneColumn();
    PopGlobalSQLState();
    if ($result) {
        return 1;
    }
    PushGlobalSQLState();
    # check if user is a member of a group that can bless this group
    # this group does not count
    SendSQL("SELECT groups.id FROM groups, user_group_map, 
        group_group_map 
        WHERE groups.id = grantor_id 
        AND user_group_map.user_id = $::userid
        AND user_group_map.isbless = 0
        AND group_group_map.isbless = 1
        AND user_group_map.group_id = member_id
        AND groups.name = " . SqlQuote($groupname));
    $result = FetchOneColumn();
    PopGlobalSQLState();
    return $result; 
}

1211 1212 1213
sub BugInGroup {
    my ($bugid, $groupname) = (@_);
    PushGlobalSQLState();
1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228
    SendSQL("SELECT bug_group_map.bug_id != 0 FROM bug_group_map, groups 
            WHERE bug_group_map.bug_id = $bugid
            AND bug_group_map.group_id = groups.id
            AND groups.name = " . SqlQuote($groupname));
    my $bugingroup = FetchOneColumn();
    PopGlobalSQLState();
    return $bugingroup;
}

sub BugInGroupId {
    my ($bugid, $groupid) = (@_);
    PushGlobalSQLState();
    SendSQL("SELECT bug_id != 0 FROM bug_group_map
            WHERE bug_id = $bugid
            AND group_id = $groupid");
1229 1230 1231 1232 1233
    my $bugingroup = FetchOneColumn();
    PopGlobalSQLState();
    return $bugingroup;
}

1234 1235
sub GroupExists {
    my ($groupname) = (@_);
1236
    PushGlobalSQLState();
1237 1238
    SendSQL("SELECT id FROM groups WHERE name=" . SqlQuote($groupname));
    my $id = FetchOneColumn();
1239
    PopGlobalSQLState();
1240
    return $id;
1241
}
1242

1243
sub GroupNameToId {
1244 1245
    my ($groupname) = (@_);
    PushGlobalSQLState();
1246 1247
    SendSQL("SELECT id FROM groups WHERE name=" . SqlQuote($groupname));
    my $id = FetchOneColumn();
1248
    PopGlobalSQLState();
1249
    return $id;
1250 1251
}

1252 1253 1254 1255 1256 1257 1258 1259 1260 1261
sub GroupIdToName {
    my ($groupid) = (@_);
    PushGlobalSQLState();
    SendSQL("SELECT name FROM groups WHERE id = $groupid");
    my $name = FetchOneColumn();
    PopGlobalSQLState();
    return $name;
}


1262 1263
# Determines whether or not a group is active by checking 
# the "isactive" column for the group in the "groups" table.
1264
# Note: This function selects groups by id rather than by name.
1265
sub GroupIsActive {
1266 1267
    my ($groupid) = (@_);
    $groupid ||= 0;
1268
    PushGlobalSQLState();
1269
    SendSQL("SELECT isactive FROM groups WHERE id=$groupid");
1270
    my $isactive = FetchOneColumn();
1271
    PopGlobalSQLState();
1272 1273 1274
    return $isactive;
}

1275
# Determines if the given bug_status string represents an "Opened" bug.  This
matty%chariot.net.au's avatar
matty%chariot.net.au committed
1276
# routine ought to be parameterizable somehow, as people tend to introduce
1277 1278 1279 1280
# new states into Bugzilla.

sub IsOpenedState {
    my ($state) = (@_);
1281
    if (grep($_ eq $state, OpenStates())) {
1282 1283 1284 1285 1286
        return 1;
    }
    return 0;
}

1287 1288 1289 1290 1291 1292 1293
# This sub will return an array containing any status that
# is considered an open bug.

sub OpenStates {
    return ('NEW', 'REOPENED', 'ASSIGNED', $::unconfirmedstate);
}

1294

1295
sub RemoveVotes {
1296 1297 1298 1299 1300
    my ($id, $who, $reason) = (@_);
    my $whopart = "";
    if ($who) {
        $whopart = " AND votes.who = $who";
    }
1301
    SendSQL("SELECT profiles.login_name, profiles.userid, votes.vote_count, " .
1302 1303 1304 1305
            "products.votesperuser, products.maxvotesperbug " .
            "FROM profiles " . 
            "LEFT JOIN votes ON profiles.userid = votes.who " .
            "LEFT JOIN bugs USING(bug_id) " .
1306
            "LEFT JOIN products ON products.id = bugs.product_id " .
1307 1308
            "WHERE votes.bug_id = $id " .
            $whopart);
1309 1310
    my @list;
    while (MoreSQLData()) {
1311 1312
        my ($name, $userid, $oldvotes, $votesperuser, $maxvotesperbug) = (FetchSQLData());
        push(@list, [$name, $userid, $oldvotes, $votesperuser, $maxvotesperbug]);
1313 1314
    }
    if (0 < @list) {
1315
        foreach my $ref (@list) {
1316 1317 1318 1319
            my ($name, $userid, $oldvotes, $votesperuser, $maxvotesperbug) = (@$ref);
            my $s;

            $maxvotesperbug = $votesperuser if ($votesperuser < $maxvotesperbug);
1320 1321 1322

            # If this product allows voting and the user's votes are in
            # the acceptable range, then don't do anything.
1323
            next if $votesperuser && $oldvotes <= $maxvotesperbug;
1324 1325 1326 1327

            # If the user has more votes on this bug than this product
            # allows, then reduce the number of votes so it fits
            my $newvotes = $votesperuser ? $maxvotesperbug : 0;
1328 1329 1330 1331 1332 1333 1334 1335 1336 1337

            my $removedvotes = $oldvotes - $newvotes;

            $s = $oldvotes == 1 ? "" : "s";
            my $oldvotestext = "You had $oldvotes vote$s on this bug.";

            $s = $removedvotes == 1 ? "" : "s";
            my $removedvotestext = "You had $removedvotes vote$s removed from this bug.";

            my $newvotestext;
1338
            if ($newvotes) {
1339
                SendSQL("UPDATE votes SET vote_count = $newvotes " .
1340
                        "WHERE bug_id = $id AND who = $userid");
1341 1342
                $s = $newvotes == 1 ? "" : "s";
                $newvotestext = "You still have $newvotes vote$s on this bug."
1343 1344
            } else {
                SendSQL("DELETE FROM votes WHERE bug_id = $id AND who = $userid");
1345
                $newvotestext = "You have no more votes remaining on this bug.";
1346 1347 1348
            }

            # Notice that we did not make sure that the user fit within the $votesperuser
1349
            # range.  This is considered to be an acceptable alternative to losing votes
1350 1351 1352 1353 1354
            # during product moves.  Then next time the user attempts to change their votes,
            # they will be forced to fit within the $votesperuser limit.

            # Now lets send the e-mail to alert the user to the fact that their votes have
            # been reduced or removed.
1355 1356 1357 1358
            my $sendmailparm = '-ODeliveryMode=deferred';
            if (Param('sendmailnow')) {
               $sendmailparm = '';
            }
1359
            if (open(SENDMAIL, "|/usr/lib/sendmail $sendmailparm -t -i")) {
1360
                my %substs;
1361

1362 1363 1364
                $substs{"to"} = $name;
                $substs{"bugid"} = $id;
                $substs{"reason"} = $reason;
1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375

                $substs{"votesremoved"} = $removedvotes;
                $substs{"votesold"} = $oldvotes;
                $substs{"votesnew"} = $newvotes;

                $substs{"votesremovedtext"} = $removedvotestext;
                $substs{"votesoldtext"} = $oldvotestext;
                $substs{"votesnewtext"} = $newvotestext;

                $substs{"count"} = $removedvotes . "\n    " . $newvotestext;

1376 1377 1378
                my $msg = PerformSubsts(Param("voteremovedmail"),
                                        \%substs);
                print SENDMAIL $msg;
1379 1380
                close SENDMAIL;
            }
1381
        }
1382
        SendSQL("SELECT SUM(vote_count) FROM votes WHERE bug_id = $id");
1383 1384 1385 1386
        my $v = FetchOneColumn();
        $v ||= 0;
        SendSQL("UPDATE bugs SET votes = $v, delta_ts = delta_ts " .
                "WHERE bug_id = $id");
1387 1388 1389
    }
}

1390 1391 1392 1393 1394
# Take two comma or space separated strings and return what
# values were removed from or added to the new one.
sub DiffStrings {
    my ($oldstr, $newstr) = @_;

1395 1396 1397 1398 1399 1400
    # Split the old and new strings into arrays containing their values.
    $oldstr =~ s/[\s,]+/ /g;
    $newstr =~ s/[\s,]+/ /g;
    my @old = split(" ", $oldstr);
    my @new = split(" ", $newstr);

1401 1402 1403
    my (@remove, @add) = ();

    # Find values that were removed
1404 1405
    foreach my $value (@old) {
        push (@remove, $value) if !grep($_ eq $value, @new);
1406 1407 1408
    }

    # Find values that were added
1409 1410
    foreach my $value (@new) {
        push (@add, $value) if !grep($_ eq $value, @old);
1411 1412 1413 1414 1415 1416 1417 1418
    }

    my $removed = join (", ", @remove);
    my $added = join (", ", @add);

    return ($removed, $added);
}

1419 1420 1421 1422 1423 1424
sub PerformSubsts {
    my ($str, $substs) = (@_);
    $str =~ s/%([a-z]*)%/(defined $substs->{$1} ? $substs->{$1} : Param($1))/eg;
    return $str;
}

1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438
sub FormatTimeUnit {
    # Returns a number with 2 digit precision, unless the last digit is a 0
    # then it returns only 1 digit precision
    my ($time) = (@_);
 
    my $newtime = sprintf("%.2f", $time);

    if ($newtime =~ /0\Z/) {
        $newtime = sprintf("%.1f", $time);
    }

    return $newtime;
    
}
1439

1440
###############################################################################
1441

1442 1443 1444
# Constructs a format object from URL parameters. You most commonly call it 
# like this:
# my $format = GetFormat("foo/bar", $::FORM{'format'}, $::FORM{'ctype'});
1445

1446 1447
sub GetFormat {
    my ($template, $format, $ctype) = @_;
1448

1449
    $ctype ||= "html";
1450
    $format ||= "";
1451

1452 1453 1454
    # Security - allow letters and a hyphen only
    $ctype =~ s/[^a-zA-Z\-]//g;
    $format =~ s/[^a-zA-Z\-]//g;
1455 1456
    trick_taint($ctype);
    trick_taint($format);
1457

1458 1459
    $template .= ($format ? "-$format" : "");
    $template .= ".$ctype.tmpl";
1460

1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477
    # Now check that the template actually exists. We only want to check
    # if the template exists; any other errors (eg parse errors) will
    # end up being detected laer.
    eval {
        Bugzilla->template->context->template($template);
    };
    # This parsing may seem fragile, but its OK:
    # http://lists.template-toolkit.org/pipermail/templates/2003-March/004370.html
    # Even if it is wrong, any sort of error is going to cause a failure
    # eventually, so the only issue would be an incorrect error message
    if ($@ && $@->info =~ /: not found$/) {
        ThrowUserError("format_not_found", { 'format' => $format,
                                             'ctype' => $ctype,
                                           });
    }

    # Else, just return the info
1478 1479 1480 1481
    return
    {
        'template'    => $template ,
        'extension'   => $ctype ,
1482
        'ctype'       => Bugzilla::Constants::contenttypes->{$ctype} ,
1483
    };
1484 1485
}

1486
############# Live code below here (that is, not subroutine defs) #############
1487

1488
use Bugzilla;
1489

1490
$::template = Bugzilla->template();
1491

1492
$::vars = {};
1493

1494
1;