aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFathi Boudra <fathi.boudra@linaro.org>2014-06-02 17:14:02 +0300
committerFathi Boudra <fathi.boudra@linaro.org>2014-06-02 17:14:02 +0300
commit46f29f243b1e7642e860b40214d5f893aad678e1 (patch)
tree7ac82cee0957299e51c36f22ce1b172670513348
downloadbugs.linaro.org-46f29f243b1e7642e860b40214d5f893aad678e1.tar.gz
Initial commit - Bugzilla 4.4.4
Signed-off-by: Fathi Boudra <fathi.boudra@linaro.org>
-rw-r--r--.bzrignore32
-rw-r--r--.gitignore32
-rw-r--r--.gitrev1
-rw-r--r--Bugzilla.pm977
-rw-r--r--Bugzilla/Attachment.pm991
-rw-r--r--Bugzilla/Attachment/PatchReader.pm286
-rw-r--r--Bugzilla/Auth.pm543
-rw-r--r--Bugzilla/Auth/Login.pm134
-rw-r--r--Bugzilla/Auth/Login/CGI.pm81
-rw-r--r--Bugzilla/Auth/Login/Cookie.pm121
-rw-r--r--Bugzilla/Auth/Login/Env.pm40
-rw-r--r--Bugzilla/Auth/Login/Stack.pm98
-rw-r--r--Bugzilla/Auth/Persist/Cookie.pm159
-rw-r--r--Bugzilla/Auth/Verify.pm235
-rw-r--r--Bugzilla/Auth/Verify/DB.pm86
-rw-r--r--Bugzilla/Auth/Verify/LDAP.pm178
-rw-r--r--Bugzilla/Auth/Verify/RADIUS.pm51
-rw-r--r--Bugzilla/Auth/Verify/Stack.pm88
-rw-r--r--Bugzilla/Bug.pm4297
-rw-r--r--Bugzilla/BugMail.pm508
-rw-r--r--Bugzilla/BugUrl.pm200
-rw-r--r--Bugzilla/BugUrl/Bugzilla.pm53
-rw-r--r--Bugzilla/BugUrl/Bugzilla/Local.pm92
-rw-r--r--Bugzilla/BugUrl/Debian.pm41
-rw-r--r--Bugzilla/BugUrl/GitHub.pm36
-rw-r--r--Bugzilla/BugUrl/Google.pm40
-rw-r--r--Bugzilla/BugUrl/JIRA.pm38
-rw-r--r--Bugzilla/BugUrl/Launchpad.pm40
-rw-r--r--Bugzilla/BugUrl/MantisBT.pm35
-rw-r--r--Bugzilla/BugUrl/SourceForge.pm40
-rw-r--r--Bugzilla/BugUrl/Trac.pm38
-rw-r--r--Bugzilla/CGI.pm676
-rw-r--r--Bugzilla/Chart.pm429
-rw-r--r--Bugzilla/Classification.pm261
-rw-r--r--Bugzilla/Comment.pm419
-rw-r--r--Bugzilla/Component.pm652
-rw-r--r--Bugzilla/Config.pm385
-rw-r--r--Bugzilla/Config/Admin.pm39
-rw-r--r--Bugzilla/Config/Advanced.pm44
-rw-r--r--Bugzilla/Config/Attachment.pm62
-rw-r--r--Bugzilla/Config/Auth.pm113
-rw-r--r--Bugzilla/Config/BugChange.pm79
-rw-r--r--Bugzilla/Config/BugFields.pm90
-rw-r--r--Bugzilla/Config/Common.pm453
-rw-r--r--Bugzilla/Config/Core.pm44
-rw-r--r--Bugzilla/Config/DependencyGraph.pm28
-rw-r--r--Bugzilla/Config/General.pm59
-rw-r--r--Bugzilla/Config/GroupSecurity.pm86
-rw-r--r--Bugzilla/Config/LDAP.pm63
-rw-r--r--Bugzilla/Config/MTA.pm90
-rw-r--r--Bugzilla/Config/PatchViewer.pm51
-rw-r--r--Bugzilla/Config/Query.pm69
-rw-r--r--Bugzilla/Config/RADIUS.pm46
-rw-r--r--Bugzilla/Config/ShadowDB.pm49
-rw-r--r--Bugzilla/Config/UserMatch.pm46
-rw-r--r--Bugzilla/Constants.pm661
-rw-r--r--Bugzilla/DB.pm2684
-rw-r--r--Bugzilla/DB/Mysql.pm1036
-rw-r--r--Bugzilla/DB/Oracle.pm783
-rw-r--r--Bugzilla/DB/Pg.pm368
-rw-r--r--Bugzilla/DB/Schema.pm3013
-rw-r--r--Bugzilla/DB/Schema/Mysql.pm383
-rw-r--r--Bugzilla/DB/Schema/Oracle.pm505
-rw-r--r--Bugzilla/DB/Schema/Pg.pm188
-rw-r--r--Bugzilla/DB/Schema/Sqlite.pm298
-rw-r--r--Bugzilla/DB/Sqlite.pm298
-rw-r--r--Bugzilla/Error.pm267
-rw-r--r--Bugzilla/Extension.pm809
-rw-r--r--Bugzilla/Field.pm1347
-rw-r--r--Bugzilla/Field/Choice.pm335
-rw-r--r--Bugzilla/Field/ChoiceInterface.pm271
-rw-r--r--Bugzilla/Flag.pm1074
-rw-r--r--Bugzilla/FlagType.pm703
-rw-r--r--Bugzilla/Group.pm649
-rw-r--r--Bugzilla/Hook.pm1599
-rw-r--r--Bugzilla/Install.pm494
-rw-r--r--Bugzilla/Install/CPAN.pm338
-rw-r--r--Bugzilla/Install/DB.pm3863
-rw-r--r--Bugzilla/Install/Filesystem.pm870
-rw-r--r--Bugzilla/Install/Localconfig.pm371
-rw-r--r--Bugzilla/Install/Requirements.pm936
-rw-r--r--Bugzilla/Install/Util.pm918
-rw-r--r--Bugzilla/Job/Mailer.pm42
-rw-r--r--Bugzilla/JobQueue.pm180
-rw-r--r--Bugzilla/JobQueue/Runner.pm237
-rw-r--r--Bugzilla/Keyword.pm167
-rw-r--r--Bugzilla/Mailer.pm208
-rw-r--r--Bugzilla/Migrate.pm1160
-rw-r--r--Bugzilla/Migrate/Gnats.pm698
-rw-r--r--Bugzilla/Milestone.pm378
-rw-r--r--Bugzilla/Object.pm1287
-rw-r--r--Bugzilla/Product.pm1069
-rw-r--r--Bugzilla/RNG.pm218
-rw-r--r--Bugzilla/Report.pm134
-rw-r--r--Bugzilla/Search.pm3187
-rw-r--r--Bugzilla/Search/Clause.pm133
-rw-r--r--Bugzilla/Search/ClauseGroup.pm99
-rw-r--r--Bugzilla/Search/Condition.pm75
-rw-r--r--Bugzilla/Search/Quicksearch.pm656
-rw-r--r--Bugzilla/Search/Recent.pm159
-rw-r--r--Bugzilla/Search/Saved.pm387
-rw-r--r--Bugzilla/Send/Sendmail.pm95
-rw-r--r--Bugzilla/Series.pm271
-rw-r--r--Bugzilla/Status.pm297
-rw-r--r--Bugzilla/Template.pm1160
-rw-r--r--Bugzilla/Template/Context.pm90
-rw-r--r--Bugzilla/Template/Plugin/Bugzilla.pm48
-rw-r--r--Bugzilla/Template/Plugin/Hook.pm148
-rw-r--r--Bugzilla/Token.pm620
-rw-r--r--Bugzilla/Update.pm200
-rw-r--r--Bugzilla/User.pm2736
-rw-r--r--Bugzilla/User/Setting.pm417
-rw-r--r--Bugzilla/User/Setting/Lang.pm47
-rw-r--r--Bugzilla/User/Setting/Skin.pm65
-rw-r--r--Bugzilla/User/Setting/Timezone.pm59
-rw-r--r--Bugzilla/UserAgent.pm238
-rw-r--r--Bugzilla/Util.pm1236
-rw-r--r--Bugzilla/Version.pm253
-rw-r--r--Bugzilla/WebService.pm320
-rw-r--r--Bugzilla/WebService/Bug.pm3494
-rw-r--r--Bugzilla/WebService/Bugzilla.pm463
-rw-r--r--Bugzilla/WebService/Classification.pm195
-rw-r--r--Bugzilla/WebService/Constants.pm206
-rw-r--r--Bugzilla/WebService/Group.pm281
-rw-r--r--Bugzilla/WebService/Product.pm858
-rw-r--r--Bugzilla/WebService/README18
-rw-r--r--Bugzilla/WebService/Server.pm56
-rw-r--r--Bugzilla/WebService/Server/JSONRPC.pm572
-rw-r--r--Bugzilla/WebService/Server/XMLRPC.pm377
-rw-r--r--Bugzilla/WebService/User.pm946
-rw-r--r--Bugzilla/WebService/Util.pm230
-rw-r--r--Bugzilla/Whine.pm119
-rw-r--r--Bugzilla/Whine/Query.pm123
-rw-r--r--Bugzilla/Whine/Schedule.pm157
-rw-r--r--README92
-rwxr-xr-xadmin.cgi36
-rwxr-xr-xattachment.cgi804
-rwxr-xr-xbuglist.cgi1055
-rwxr-xr-xchart.cgi355
-rwxr-xr-xchecksetup.pl495
-rwxr-xr-xcolchange.cgi184
-rwxr-xr-xcollectstats.pl536
-rwxr-xr-xconfig.cgi153
-rw-r--r--contrib/Bugzilla.pm48
-rw-r--r--contrib/README57
-rwxr-xr-xcontrib/bugzilla-queue.rhel116
-rwxr-xr-xcontrib/bugzilla-queue.suse181
-rw-r--r--contrib/bugzilla-submit/README46
-rwxr-xr-xcontrib/bugzilla-submit/bugdata.txt13
-rwxr-xr-xcontrib/bugzilla-submit/bugzilla-submit307
-rwxr-xr-xcontrib/bugzilla-submit/bugzilla-submit.xml227
-rwxr-xr-xcontrib/bz_webservice_demo.pl434
-rwxr-xr-xcontrib/bzdbcopy.pl251
-rwxr-xr-xcontrib/cmdline/bugcount14
-rwxr-xr-xcontrib/cmdline/bugids19
-rwxr-xr-xcontrib/cmdline/buglist18
-rwxr-xr-xcontrib/cmdline/bugs13
-rwxr-xr-xcontrib/cmdline/bugslink14
-rwxr-xr-xcontrib/cmdline/makequery95
-rwxr-xr-xcontrib/cmdline/query.conf36
-rwxr-xr-xcontrib/console.pl172
-rwxr-xr-xcontrib/convert-workflow.pl165
-rwxr-xr-xcontrib/cvs-update.pl35
-rwxr-xr-xcontrib/extension-convert.pl290
-rwxr-xr-xcontrib/fixperms.pl15
-rwxr-xr-xcontrib/jb2bz.py338
-rwxr-xr-xcontrib/merge-users.pl225
-rwxr-xr-xcontrib/mysqld-watcher.pl102
-rwxr-xr-xcontrib/new-yui.sh24
-rwxr-xr-xcontrib/recode.pl318
-rwxr-xr-xcontrib/sendbugmail.pl104
-rwxr-xr-xcontrib/sendunsentbugmail.pl51
-rwxr-xr-xcontrib/syncLDAP.pl276
-rwxr-xr-xcreateaccount.cgi47
-rwxr-xr-xdescribecomponents.cgi73
-rwxr-xr-xdescribekeywords.cgi31
-rw-r--r--docs/bugzilla.ent.tmpl7
-rw-r--r--docs/en/README.docs42
-rw-r--r--docs/en/images/bzLifecycle.pngbin0 -> 36761 bytes
-rw-r--r--docs/en/images/bzLifecycle.xml1731
-rw-r--r--docs/en/images/callouts/1.gifbin0 -> 890 bytes
-rw-r--r--docs/en/images/callouts/2.gifbin0 -> 907 bytes
-rw-r--r--docs/en/images/callouts/3.gifbin0 -> 914 bytes
-rw-r--r--docs/en/images/caution.gifbin0 -> 134 bytes
-rw-r--r--docs/en/images/note.gifbin0 -> 226 bytes
-rw-r--r--docs/en/images/tip.gifbin0 -> 1229 bytes
-rw-r--r--docs/en/images/warning.gifbin0 -> 140 bytes
-rw-r--r--docs/en/rel_notes.txt3028
-rw-r--r--docs/en/xml/Bugzilla-Guide.xml134
-rw-r--r--docs/en/xml/about.xml223
-rw-r--r--docs/en/xml/administration.xml3207
-rw-r--r--docs/en/xml/conventions.xml91
-rw-r--r--docs/en/xml/customization.xml612
-rw-r--r--docs/en/xml/gfdl.xml457
-rw-r--r--docs/en/xml/glossary.xml561
-rw-r--r--docs/en/xml/index.xml27
-rw-r--r--docs/en/xml/installation.xml2453
-rw-r--r--docs/en/xml/modules.xml188
-rw-r--r--docs/en/xml/patches.xml143
-rw-r--r--docs/en/xml/security.xml281
-rw-r--r--docs/en/xml/troubleshooting.xml287
-rw-r--r--docs/en/xml/using.xml2077
-rw-r--r--docs/lib/Pod/Simple/HTML/Bugzilla.pm65
-rw-r--r--docs/lib/Pod/Simple/HTMLBatch/Bugzilla.pm107
-rwxr-xr-xdocs/makedocs.pl179
-rw-r--r--docs/style.css95
-rw-r--r--docs/xsl/bugzilla-docs.xsl36
-rw-r--r--docs/xsl/chunks.xsl19
-rw-r--r--docs/xsl/nochunks.xsl16
-rw-r--r--docs/xsl/pdf.xsl42
-rwxr-xr-xduplicates.cgi247
-rwxr-xr-xeditclassifications.cgi225
-rwxr-xr-xeditcomponents.cgi242
-rwxr-xr-xeditfields.cgi168
-rwxr-xr-xeditflagtypes.cgi534
-rwxr-xr-xeditgroups.cgi454
-rwxr-xr-xeditkeywords.cgi173
-rwxr-xr-xeditmilestones.cgi216
-rwxr-xr-xeditparams.cgi151
-rwxr-xr-xeditproducts.cgi427
-rwxr-xr-xeditsettings.cgi63
-rwxr-xr-xeditusers.cgi793
-rwxr-xr-xeditvalues.cgi192
-rwxr-xr-xeditversions.cgi208
-rwxr-xr-xeditwhines.cgi401
-rwxr-xr-xeditworkflow.cgi138
-rwxr-xr-xemail_in.pl677
-rwxr-xr-xenter_bug.cgi353
-rw-r--r--extensions/BmpConvert/Config.pm19
-rw-r--r--extensions/BmpConvert/Extension.pm42
-rw-r--r--extensions/BmpConvert/disabled0
-rw-r--r--extensions/Example/Config.pm28
-rw-r--r--extensions/Example/Extension.pm944
-rw-r--r--extensions/Example/disabled0
-rw-r--r--extensions/Example/lib/Auth/Login.pm19
-rw-r--r--extensions/Example/lib/Auth/Verify.pm18
-rw-r--r--extensions/Example/lib/Config.pm29
-rw-r--r--extensions/Example/lib/Util.pm15
-rw-r--r--extensions/Example/lib/WebService.pm19
-rw-r--r--extensions/Example/template/en/default/account/prefs/my_tab.html.tmpl18
-rw-r--r--extensions/Example/template/en/default/admin/params/example.html.tmpl16
-rw-r--r--extensions/Example/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl11
-rw-r--r--extensions/Example/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl11
-rw-r--r--extensions/Example/template/en/default/hook/admin/sanitycheck/messages-statuses.html.tmpl23
-rw-r--r--extensions/Example/template/en/default/hook/global/footer-end.html.tmpl16
-rw-r--r--extensions/Example/template/en/default/hook/global/setting-descs-settings.none.tmpl14
-rw-r--r--extensions/Example/template/en/default/hook/global/user-error-errors.html.tmpl14
-rw-r--r--extensions/Example/template/en/default/pages/example.html.tmpl19
-rw-r--r--extensions/Example/template/en/default/setup/strings.txt.pl12
-rw-r--r--extensions/MoreBugUrl/Config.pm19
-rw-r--r--extensions/MoreBugUrl/Extension.pm46
-rw-r--r--extensions/MoreBugUrl/disabled0
-rw-r--r--extensions/MoreBugUrl/lib/GetSatisfaction.pm37
-rw-r--r--extensions/MoreBugUrl/lib/PHP.pm40
-rw-r--r--extensions/MoreBugUrl/lib/RT.pm37
-rw-r--r--extensions/MoreBugUrl/lib/ReviewBoard.pm42
-rw-r--r--extensions/MoreBugUrl/lib/Rietveld.pm45
-rw-r--r--extensions/MoreBugUrl/template/en/default/hook/global/user-error-bug_url_invalid_tracker.html.tmpl13
-rw-r--r--extensions/OldBugMove/Config.pm11
-rw-r--r--extensions/OldBugMove/Extension.pm196
-rw-r--r--extensions/OldBugMove/disabled0
-rw-r--r--extensions/OldBugMove/lib/Params.pm36
-rw-r--r--extensions/OldBugMove/template/en/default/admin/params/oldbugmove.html.tmpl27
-rw-r--r--extensions/OldBugMove/template/en/default/hook/bug/edit-after_comment_textarea.html.tmpl15
-rw-r--r--extensions/OldBugMove/template/en/default/hook/bug/format_comment-type.txt.tmpl17
-rw-r--r--extensions/OldBugMove/template/en/default/hook/global/user-error-auth_failure_action.html.tmpl11
-rw-r--r--extensions/OldBugMove/template/en/default/hook/global/user-error-errors.html.tmpl17
-rw-r--r--extensions/OldBugMove/template/en/default/hook/list/edit-multiple-after_groups.html.tmpl15
-rw-r--r--extensions/Voting/Config.pm19
-rw-r--r--extensions/Voting/Extension.pm867
-rw-r--r--extensions/Voting/disabled0
-rw-r--r--extensions/Voting/template/en/default/hook/account/prefs/email-relationships.html.tmpl10
-rw-r--r--extensions/Voting/template/en/default/hook/admin/products/edit-common-rows.html.tmpl47
-rw-r--r--extensions/Voting/template/en/default/hook/admin/products/updated-changes.html.tmpl90
-rw-r--r--extensions/Voting/template/en/default/hook/admin/sanitycheck/messages-statuses.html.tmpl29
-rw-r--r--extensions/Voting/template/en/default/hook/admin/users/confirm-delete-warn_safe.html.tmpl26
-rw-r--r--extensions/Voting/template/en/default/hook/bug/edit-after_importance.html.tmpl25
-rw-r--r--extensions/Voting/template/en/default/hook/bug/format_comment-type.txt.tmpl11
-rw-r--r--extensions/Voting/template/en/default/hook/bug/process/header-title.html.tmpl11
-rw-r--r--extensions/Voting/template/en/default/hook/bug/process/results-title.html.tmpl9
-rw-r--r--extensions/Voting/template/en/default/hook/bug/show-header-end.html.tmpl9
-rw-r--r--extensions/Voting/template/en/default/hook/global/code-error-errors.html.tmpl13
-rw-r--r--extensions/Voting/template/en/default/hook/global/field-descs-end.none.tmpl11
-rw-r--r--extensions/Voting/template/en/default/hook/global/reason-descs-end.none.tmpl11
-rw-r--r--extensions/Voting/template/en/default/hook/global/user-error-errors.html.tmpl43
-rw-r--r--extensions/Voting/template/en/default/hook/search/form-after_freetext_fields.html.tmpl16
-rw-r--r--extensions/Voting/template/en/default/hook/search/search-report-select-rep_fields.html.tmpl9
-rw-r--r--extensions/Voting/template/en/default/pages/voting.html.tmpl55
-rw-r--r--extensions/Voting/template/en/default/pages/voting/bug.html.tmpl50
-rw-r--r--extensions/Voting/template/en/default/pages/voting/user.html.tmpl178
-rw-r--r--extensions/Voting/template/en/default/voting/delete-all.html.tmpl38
-rw-r--r--extensions/Voting/template/en/default/voting/votes-removed.txt.tmpl40
-rw-r--r--extensions/Voting/web/style.css21
-rwxr-xr-xextensions/create.pl71
-rw-r--r--images/favicon.icobin0 -> 4150 bytes
-rw-r--r--images/padlock.pngbin0 -> 1066 bytes
-rwxr-xr-ximportxml.pl1352
-rwxr-xr-xindex.cgi60
-rwxr-xr-xinstall-module.pl161
-rwxr-xr-xjobqueue.pl73
-rw-r--r--js/TUI.js96
-rw-r--r--js/attachment.js342
-rw-r--r--js/bug.js120
-rw-r--r--js/change-columns.js132
-rw-r--r--js/comments.js157
-rw-r--r--js/custom-search.js344
-rw-r--r--js/expanding-tree.js142
-rw-r--r--js/field.js983
-rw-r--r--js/flag.js57
-rw-r--r--js/global.js121
-rw-r--r--js/history.js/license.txt10
-rw-r--r--js/history.js/native.history.js1
-rw-r--r--js/history.js/readme.txt2
-rw-r--r--js/params.js50
-rw-r--r--js/productform.js327
-rw-r--r--js/util.js305
-rw-r--r--js/yui/animation/animation-min.js23
-rw-r--r--js/yui/assets/skins/sam/ajax-loader.gifbin0 -> 3208 bytes
-rw-r--r--js/yui/assets/skins/sam/asc.gifbin0 -> 177 bytes
-rw-r--r--js/yui/assets/skins/sam/autocomplete.css7
-rw-r--r--js/yui/assets/skins/sam/back-h.pngbin0 -> 334 bytes
-rw-r--r--js/yui/assets/skins/sam/back-v.pngbin0 -> 338 bytes
-rw-r--r--js/yui/assets/skins/sam/bar-h.pngbin0 -> 365 bytes
-rw-r--r--js/yui/assets/skins/sam/bar-v.pngbin0 -> 387 bytes
-rw-r--r--js/yui/assets/skins/sam/bg-h.gifbin0 -> 212 bytes
-rw-r--r--js/yui/assets/skins/sam/bg-v.gifbin0 -> 481 bytes
-rw-r--r--js/yui/assets/skins/sam/blankimage.pngbin0 -> 2314 bytes
-rw-r--r--js/yui/assets/skins/sam/button.css7
-rw-r--r--js/yui/assets/skins/sam/calendar.css8
-rw-r--r--js/yui/assets/skins/sam/carousel.css7
-rw-r--r--js/yui/assets/skins/sam/check0.gifbin0 -> 608 bytes
-rw-r--r--js/yui/assets/skins/sam/check1.gifbin0 -> 622 bytes
-rw-r--r--js/yui/assets/skins/sam/check2.gifbin0 -> 609 bytes
-rw-r--r--js/yui/assets/skins/sam/colorpicker.css7
-rw-r--r--js/yui/assets/skins/sam/container.css7
-rw-r--r--js/yui/assets/skins/sam/datatable.css8
-rw-r--r--js/yui/assets/skins/sam/desc.gifbin0 -> 177 bytes
-rw-r--r--js/yui/assets/skins/sam/dt-arrow-dn.pngbin0 -> 116 bytes
-rw-r--r--js/yui/assets/skins/sam/dt-arrow-up.pngbin0 -> 116 bytes
-rw-r--r--js/yui/assets/skins/sam/editor-knob.gifbin0 -> 138 bytes
-rw-r--r--js/yui/assets/skins/sam/editor-sprite-active.gifbin0 -> 5614 bytes
-rw-r--r--js/yui/assets/skins/sam/editor-sprite.gifbin0 -> 5690 bytes
-rw-r--r--js/yui/assets/skins/sam/editor.css10
-rw-r--r--js/yui/assets/skins/sam/header_background.pngbin0 -> 158 bytes
-rw-r--r--js/yui/assets/skins/sam/hue_bg.pngbin0 -> 1120 bytes
-rw-r--r--js/yui/assets/skins/sam/imagecropper.css7
-rw-r--r--js/yui/assets/skins/sam/layout.css7
-rw-r--r--js/yui/assets/skins/sam/layout_sprite.pngbin0 -> 1409 bytes
-rw-r--r--js/yui/assets/skins/sam/loading.gifbin0 -> 2673 bytes
-rw-r--r--js/yui/assets/skins/sam/logger.css7
-rw-r--r--js/yui/assets/skins/sam/menu-button-arrow-disabled.pngbin0 -> 173 bytes
-rw-r--r--js/yui/assets/skins/sam/menu-button-arrow.pngbin0 -> 173 bytes
-rw-r--r--js/yui/assets/skins/sam/menu.css7
-rw-r--r--js/yui/assets/skins/sam/menubaritem_submenuindicator.pngbin0 -> 3618 bytes
-rw-r--r--js/yui/assets/skins/sam/menubaritem_submenuindicator_disabled.pngbin0 -> 3618 bytes
-rw-r--r--js/yui/assets/skins/sam/menuitem_checkbox.pngbin0 -> 3625 bytes
-rw-r--r--js/yui/assets/skins/sam/menuitem_checkbox_disabled.pngbin0 -> 3625 bytes
-rw-r--r--js/yui/assets/skins/sam/menuitem_submenuindicator.pngbin0 -> 3617 bytes
-rw-r--r--js/yui/assets/skins/sam/menuitem_submenuindicator_disabled.pngbin0 -> 3617 bytes
-rw-r--r--js/yui/assets/skins/sam/paginator.css7
-rw-r--r--js/yui/assets/skins/sam/picker_mask.pngbin0 -> 12174 bytes
-rw-r--r--js/yui/assets/skins/sam/profilerviewer.css7
-rw-r--r--js/yui/assets/skins/sam/progressbar.css7
-rw-r--r--js/yui/assets/skins/sam/resize.css7
-rw-r--r--js/yui/assets/skins/sam/simpleeditor.css10
-rw-r--r--js/yui/assets/skins/sam/slider.css7
-rw-r--r--js/yui/assets/skins/sam/split-button-arrow-active.pngbin0 -> 280 bytes
-rw-r--r--js/yui/assets/skins/sam/split-button-arrow-disabled.pngbin0 -> 185 bytes
-rw-r--r--js/yui/assets/skins/sam/split-button-arrow-focus.pngbin0 -> 185 bytes
-rw-r--r--js/yui/assets/skins/sam/split-button-arrow-hover.pngbin0 -> 185 bytes
-rw-r--r--js/yui/assets/skins/sam/split-button-arrow.pngbin0 -> 185 bytes
-rw-r--r--js/yui/assets/skins/sam/sprite.pngbin0 -> 3745 bytes
-rw-r--r--js/yui/assets/skins/sam/tabview.css8
-rw-r--r--js/yui/assets/skins/sam/treeview-loading.gifbin0 -> 2673 bytes
-rw-r--r--js/yui/assets/skins/sam/treeview-sprite.gifbin0 -> 4326 bytes
-rw-r--r--js/yui/assets/skins/sam/treeview.css7
-rw-r--r--js/yui/assets/skins/sam/wait.gifbin0 -> 1100 bytes
-rw-r--r--js/yui/assets/skins/sam/yuitest.css7
-rw-r--r--js/yui/autocomplete/autocomplete-min.js12
-rw-r--r--js/yui/base/base-min.css7
-rw-r--r--js/yui/base/base.css137
-rw-r--r--js/yui/button/button-min.js11
-rw-r--r--js/yui/calendar/calendar-min.js18
-rw-r--r--js/yui/carousel/carousel-min.js12
-rw-r--r--js/yui/charts/charts-min.js9
-rw-r--r--js/yui/colorpicker/colorpicker-min.js9
-rw-r--r--js/yui/connection/connection-min.js9
-rw-r--r--js/yui/connection/connection.swfbin0 -> 2423 bytes
-rw-r--r--js/yui/connection/connection_core-min.js8
-rw-r--r--js/yui/container/container-min.js19
-rw-r--r--js/yui/container/container_core-min.js14
-rw-r--r--js/yui/cookie/cookie-min.js7
-rw-r--r--js/yui/datasource/datasource-min.js12
-rw-r--r--js/yui/datatable/datatable-min.js33
-rw-r--r--js/yui/datemath/datemath-min.js7
-rw-r--r--js/yui/dom/dom-min.js9
-rw-r--r--js/yui/dragdrop/dragdrop-min.js10
-rw-r--r--js/yui/element-delegate/element-delegate-min.js7
-rw-r--r--js/yui/element/element-min.js8
-rw-r--r--js/yui/event-delegate/event-delegate-min.js7
-rw-r--r--js/yui/event-mouseenter/event-mouseenter-min.js7
-rw-r--r--js/yui/event-simulate/event-simulate-min.js7
-rw-r--r--js/yui/event/event-min.js11
-rw-r--r--js/yui/fonts/fonts-min.css7
-rw-r--r--js/yui/fonts/fonts.css55
-rw-r--r--js/yui/get/get-min.js7
-rw-r--r--js/yui/grids/grids-min.css7
-rw-r--r--js/yui/grids/grids.css465
-rw-r--r--js/yui/history/history-min.js7
-rw-r--r--js/yui/imagecropper/imagecropper-min.js8
-rw-r--r--js/yui/imageloader/imageloader-min.js7
-rw-r--r--js/yui/json/json-min.js7
-rw-r--r--js/yui/layout/layout-min.js11
-rw-r--r--js/yui/logger/logger-min.js9
-rw-r--r--js/yui/menu/menu-min.js16
-rw-r--r--js/yui/paginator/paginator-min.js11
-rw-r--r--js/yui/profiler/profiler-min.js7
-rw-r--r--js/yui/profilerviewer/profilerviewer-min.js9
-rw-r--r--js/yui/progressbar/progressbar-min.js8
-rw-r--r--js/yui/reset-fonts-grids/reset-fonts-grids.css7
-rw-r--r--js/yui/reset-fonts/reset-fonts.css7
-rw-r--r--js/yui/reset/reset-min.css7
-rw-r--r--js/yui/reset/reset.css123
-rw-r--r--js/yui/resize/resize-min.js10
-rw-r--r--js/yui/selector/selector-min.js8
-rw-r--r--js/yui/slider/slider-min.js9
-rw-r--r--js/yui/storage/storage-min.js8
-rw-r--r--js/yui/stylesheet/stylesheet-min.js7
-rw-r--r--js/yui/swf/swf-min.js7
-rw-r--r--js/yui/swfdetect/swfdetect-min.js7
-rw-r--r--js/yui/swfstore/swfstore-min.js7
-rw-r--r--js/yui/swfstore/swfstore.swfbin0 -> 4841 bytes
-rw-r--r--js/yui/tabview/tabview-min.js8
-rw-r--r--js/yui/treeview/treeview-min.js12
-rw-r--r--js/yui/uploader/uploader-min.js15
-rw-r--r--js/yui/yahoo-dom-event/yahoo-dom-event.js14
-rw-r--r--js/yui/yahoo/yahoo-min.js8
-rw-r--r--js/yui/yuiloader/yuiloader-min.js11
-rw-r--r--js/yui/yuitest/yuitest-min.js11
-rw-r--r--js/yui/yuitest/yuitest_core-min.js9
-rwxr-xr-xjsonrpc.cgi27
-rwxr-xr-xmigrate.pl96
-rw-r--r--mod_perl.pl151
-rwxr-xr-xpage.cgi83
-rwxr-xr-xpost_bug.cgi225
-rwxr-xr-xprocess_bug.cgi418
-rwxr-xr-xquery.cgi287
-rwxr-xr-xquips.cgi137
-rwxr-xr-xrelogin.cgi208
-rwxr-xr-xreport.cgi396
-rwxr-xr-xreports.cgi223
-rwxr-xr-xrequest.cgi350
-rw-r--r--robots.txt3
-rwxr-xr-xruntests.pl28
-rwxr-xr-xsanitycheck.cgi925
-rwxr-xr-xsanitycheck.pl101
-rwxr-xr-xsearch_plugin.cgi34
-rwxr-xr-xshow_activity.cgi48
-rwxr-xr-xshow_bug.cgi129
-rwxr-xr-xshowdependencygraph.cgi319
-rwxr-xr-xshowdependencytree.cgi144
-rw-r--r--skins/README20
-rw-r--r--skins/contrib/Dusk/buglist.css11
-rw-r--r--skins/contrib/Dusk/global.css239
-rw-r--r--skins/contrib/Dusk/index.css12
-rw-r--r--skins/standard/IE-fixes.css58
-rw-r--r--skins/standard/admin.css126
-rw-r--r--skins/standard/attachment.css236
-rw-r--r--skins/standard/buglist.css131
-rw-r--r--skins/standard/dependency-tree.css80
-rw-r--r--skins/standard/dependency-tree/bug-item.pngbin0 -> 1279 bytes
-rw-r--r--skins/standard/dependency-tree/tree-closed.pngbin0 -> 1129 bytes
-rw-r--r--skins/standard/dependency-tree/tree-open.pngbin0 -> 1130 bytes
-rw-r--r--skins/standard/dependency-tree/tree.pngbin0 -> 961 bytes
-rw-r--r--skins/standard/duplicates.css37
-rw-r--r--skins/standard/editusers.css64
-rw-r--r--skins/standard/enter_bug.css55
-rw-r--r--skins/standard/global.css551
-rw-r--r--skins/standard/global/body-back.gifbin0 -> 526 bytes
-rw-r--r--skins/standard/global/calendar.pngbin0 -> 718 bytes
-rw-r--r--skins/standard/global/header.pngbin0 -> 2042 bytes
-rw-r--r--skins/standard/index.css135
-rw-r--r--skins/standard/index/file-a-bug.pngbin0 -> 3534 bytes
-rw-r--r--skins/standard/index/help.pngbin0 -> 4111 bytes
-rw-r--r--skins/standard/index/new-account.pngbin0 -> 4082 bytes
-rw-r--r--skins/standard/index/search.pngbin0 -> 4828 bytes
-rw-r--r--skins/standard/page.css92
-rw-r--r--skins/standard/params.css69
-rw-r--r--skins/standard/reports.css104
-rw-r--r--skins/standard/search_form.css190
-rw-r--r--skins/standard/show_bug.css123
-rw-r--r--skins/standard/show_multiple.css60
-rw-r--r--skins/standard/summarize-time.css37
-rwxr-xr-xsummarize_time.cgi362
-rw-r--r--t/001compile.t92
-rw-r--r--t/002goodperl.t113
-rw-r--r--t/003safesys.t51
-rw-r--r--t/004template.t124
-rw-r--r--t/005whitespace.t64
-rw-r--r--t/006spellcheck.t86
-rw-r--r--t/007util.t83
-rw-r--r--t/008filter.t220
-rw-r--r--t/009bugwords.t86
-rw-r--r--t/010dependencies.t108
-rw-r--r--t/011pod.t51
-rw-r--r--t/012throwables.t234
-rw-r--r--t/Support/Files.pm48
-rw-r--r--t/Support/Systemexec.pm19
-rw-r--r--t/Support/Templates.pm127
-rw-r--r--template/en/default/account/auth/login-small.html.tmpl115
-rw-r--r--template/en/default/account/auth/login.html.tmpl118
-rw-r--r--template/en/default/account/cancel-token.txt.tmpl91
-rw-r--r--template/en/default/account/create.html.tmpl75
-rw-r--r--template/en/default/account/created.html.tmpl25
-rw-r--r--template/en/default/account/email/change-new.txt.tmpl27
-rw-r--r--template/en/default/account/email/change-old.txt.tmpl27
-rw-r--r--template/en/default/account/email/confirm-new.html.tmpl72
-rw-r--r--template/en/default/account/email/confirm.html.tmpl35
-rw-r--r--template/en/default/account/email/request-new.txt.tmpl47
-rw-r--r--template/en/default/account/password/forgotten-password.txt.tmpl26
-rw-r--r--template/en/default/account/password/set-forgotten-password.html.tmpl53
-rw-r--r--template/en/default/account/prefs/account.html.tmpl91
-rw-r--r--template/en/default/account/prefs/email.html.tmpl292
-rw-r--r--template/en/default/account/prefs/permissions.html.tmpl79
-rw-r--r--template/en/default/account/prefs/prefs.html.tmpl102
-rw-r--r--template/en/default/account/prefs/saved-searches.html.tmpl197
-rw-r--r--template/en/default/account/prefs/settings.html.tmpl68
-rw-r--r--template/en/default/account/profile-activity.html.tmpl73
-rw-r--r--template/en/default/admin/admin.html.tmpl127
-rw-r--r--template/en/default/admin/classifications/add.html.tmpl27
-rw-r--r--template/en/default/admin/classifications/del.html.tmpl51
-rw-r--r--template/en/default/admin/classifications/edit-common.html.tmpl35
-rw-r--r--template/en/default/admin/classifications/edit.html.tmpl55
-rw-r--r--template/en/default/admin/classifications/footer.html.tmpl10
-rw-r--r--template/en/default/admin/classifications/reclassify.html.tmpl77
-rw-r--r--template/en/default/admin/classifications/select.html.tmpl54
-rw-r--r--template/en/default/admin/components/confirm-delete.html.tmpl149
-rw-r--r--template/en/default/admin/components/create.html.tmpl36
-rw-r--r--template/en/default/admin/components/edit-common.html.tmpl77
-rw-r--r--template/en/default/admin/components/edit.html.tmpl66
-rw-r--r--template/en/default/admin/components/footer.html.tmpl42
-rw-r--r--template/en/default/admin/components/list.html.tmpl133
-rw-r--r--template/en/default/admin/components/select-product.html.tmpl54
-rw-r--r--template/en/default/admin/confirm-action.html.tmpl87
-rw-r--r--template/en/default/admin/custom_fields/cf-js.js.tmpl66
-rw-r--r--template/en/default/admin/custom_fields/confirm-delete.html.tmpl60
-rw-r--r--template/en/default/admin/custom_fields/create.html.tmpl179
-rw-r--r--template/en/default/admin/custom_fields/edit.html.tmpl195
-rw-r--r--template/en/default/admin/custom_fields/list.html.tmpl99
-rw-r--r--template/en/default/admin/fieldvalues/confirm-delete.html.tmpl154
-rw-r--r--template/en/default/admin/fieldvalues/create.html.tmpl92
-rw-r--r--template/en/default/admin/fieldvalues/edit.html.tmpl105
-rw-r--r--template/en/default/admin/fieldvalues/footer.html.tmpl48
-rw-r--r--template/en/default/admin/fieldvalues/list.html.tmpl90
-rw-r--r--template/en/default/admin/fieldvalues/select-field.html.tmpl37
-rw-r--r--template/en/default/admin/flag-type/confirm-delete.html.tmpl49
-rw-r--r--template/en/default/admin/flag-type/edit.html.tmpl265
-rw-r--r--template/en/default/admin/flag-type/list.html.tmpl159
-rw-r--r--template/en/default/admin/groups/confirm-remove.html.tmpl50
-rw-r--r--template/en/default/admin/groups/create.html.tmpl94
-rw-r--r--template/en/default/admin/groups/delete.html.tmpl172
-rw-r--r--template/en/default/admin/groups/edit.html.tmpl233
-rw-r--r--template/en/default/admin/groups/list.html.tmpl153
-rw-r--r--template/en/default/admin/keywords/confirm-delete.html.tmpl39
-rw-r--r--template/en/default/admin/keywords/create.html.tmpl45
-rw-r--r--template/en/default/admin/keywords/edit.html.tmpl57
-rw-r--r--template/en/default/admin/keywords/list.html.tmpl54
-rw-r--r--template/en/default/admin/milestones/confirm-delete.html.tmpl83
-rw-r--r--template/en/default/admin/milestones/create.html.tmpl46
-rw-r--r--template/en/default/admin/milestones/edit.html.tmpl53
-rw-r--r--template/en/default/admin/milestones/footer.html.tmpl55
-rw-r--r--template/en/default/admin/milestones/list.html.tmpl98
-rw-r--r--template/en/default/admin/milestones/select-product.html.tmpl54
-rw-r--r--template/en/default/admin/params/admin.html.tmpl28
-rw-r--r--template/en/default/admin/params/advanced.html.tmpl68
-rw-r--r--template/en/default/admin/params/attachment.html.tmpl62
-rw-r--r--template/en/default/admin/params/auth.html.tmpl130
-rw-r--r--template/en/default/admin/params/bugchange.html.tmpl42
-rw-r--r--template/en/default/admin/params/bugfields.html.tmpl45
-rw-r--r--template/en/default/admin/params/common.html.tmpl125
-rw-r--r--template/en/default/admin/params/core.html.tmpl35
-rw-r--r--template/en/default/admin/params/dependencygraph.html.tmpl36
-rw-r--r--template/en/default/admin/params/editparams.html.tmpl103
-rw-r--r--template/en/default/admin/params/general.html.tmpl73
-rw-r--r--template/en/default/admin/params/groupsecurity.html.tmpl46
-rw-r--r--template/en/default/admin/params/index.html.tmpl38
-rw-r--r--template/en/default/admin/params/ldap.html.tmpl45
-rw-r--r--template/en/default/admin/params/mta.html.tmpl67
-rw-r--r--template/en/default/admin/params/patchviewer.html.tmpl51
-rw-r--r--template/en/default/admin/params/query.html.tmpl64
-rw-r--r--template/en/default/admin/params/radius.html.tmpl43
-rw-r--r--template/en/default/admin/params/shadowdb.html.tmpl36
-rw-r--r--template/en/default/admin/params/usermatch.html.tmpl29
-rw-r--r--template/en/default/admin/products/confirm-delete.html.tmpl259
-rw-r--r--template/en/default/admin/products/create.html.tmpl74
-rw-r--r--template/en/default/admin/products/edit-common.html.tmpl72
-rw-r--r--template/en/default/admin/products/edit.html.tmpl142
-rw-r--r--template/en/default/admin/products/footer.html.tmpl79
-rw-r--r--template/en/default/admin/products/groupcontrol/confirm-edit.html.tmpl42
-rw-r--r--template/en/default/admin/products/groupcontrol/edit.html.tmpl303
-rw-r--r--template/en/default/admin/products/groupcontrol/updated.html.tmpl42
-rw-r--r--template/en/default/admin/products/list-classifications.html.tmpl58
-rw-r--r--template/en/default/admin/products/list.html.tmpl86
-rw-r--r--template/en/default/admin/products/updated.html.tmpl90
-rw-r--r--template/en/default/admin/sanitycheck/list.html.tmpl26
-rw-r--r--template/en/default/admin/sanitycheck/messages.html.tmpl312
-rw-r--r--template/en/default/admin/settings/edit.html.tmpl93
-rw-r--r--template/en/default/admin/sudo.html.tmpl91
-rw-r--r--template/en/default/admin/table.html.tmpl164
-rw-r--r--template/en/default/admin/users/confirm-delete.html.tmpl471
-rw-r--r--template/en/default/admin/users/create.html.tmpl51
-rw-r--r--template/en/default/admin/users/edit.html.tmpl173
-rw-r--r--template/en/default/admin/users/list.html.tmpl111
-rw-r--r--template/en/default/admin/users/listselectvars.html.tmpl26
-rw-r--r--template/en/default/admin/users/responsibilities.html.tmpl57
-rw-r--r--template/en/default/admin/users/search.html.tmpl79
-rw-r--r--template/en/default/admin/users/userdata.html.tmpl109
-rw-r--r--template/en/default/admin/versions/confirm-delete.html.tmpl89
-rw-r--r--template/en/default/admin/versions/create.html.tmpl40
-rw-r--r--template/en/default/admin/versions/edit.html.tmpl47
-rw-r--r--template/en/default/admin/versions/footer.html.tmpl53
-rw-r--r--template/en/default/admin/versions/list.html.tmpl79
-rw-r--r--template/en/default/admin/versions/select-product.html.tmpl54
-rw-r--r--template/en/default/admin/workflow/comment.html.tmpl82
-rw-r--r--template/en/default/admin/workflow/edit.html.tmpl100
-rw-r--r--template/en/default/attachment/choose.html.tmpl29
-rw-r--r--template/en/default/attachment/confirm-delete.html.tmpl83
-rw-r--r--template/en/default/attachment/create.html.tmpl133
-rw-r--r--template/en/default/attachment/created.html.tmpl54
-rw-r--r--template/en/default/attachment/createformcontents.html.tmpl98
-rw-r--r--template/en/default/attachment/delete_reason.txt.tmpl18
-rw-r--r--template/en/default/attachment/diff-file.html.tmpl164
-rw-r--r--template/en/default/attachment/diff-footer.html.tmpl21
-rw-r--r--template/en/default/attachment/diff-header.html.tmpl139
-rw-r--r--template/en/default/attachment/edit.html.tmpl331
-rw-r--r--template/en/default/attachment/list.html.tmpl165
-rw-r--r--template/en/default/attachment/midair.html.tmpl64
-rw-r--r--template/en/default/attachment/show-multiple.html.tmpl106
-rw-r--r--template/en/default/attachment/updated.html.tmpl32
-rw-r--r--template/en/default/bug/activity/show.html.tmpl35
-rw-r--r--template/en/default/bug/activity/table.html.tmpl103
-rw-r--r--template/en/default/bug/choose.html.tmpl21
-rw-r--r--template/en/default/bug/comments.html.tmpl196
-rw-r--r--template/en/default/bug/create/comment-guided.txt.tmpl34
-rw-r--r--template/en/default/bug/create/comment.txt.tmpl12
-rw-r--r--template/en/default/bug/create/create-guided.html.tmpl511
-rw-r--r--template/en/default/bug/create/create.html.tmpl727
-rw-r--r--template/en/default/bug/create/created.html.tmpl47
-rw-r--r--template/en/default/bug/create/make-template.html.tmpl31
-rw-r--r--template/en/default/bug/create/user-message.html.tmpl22
-rw-r--r--template/en/default/bug/dependency-graph.html.tmpl89
-rw-r--r--template/en/default/bug/dependency-tree.html.tmpl265
-rw-r--r--template/en/default/bug/edit.html.tmpl1172
-rw-r--r--template/en/default/bug/field-events.js.tmpl59
-rw-r--r--template/en/default/bug/field-help.none.tmpl243
-rw-r--r--template/en/default/bug/field-label.html.tmpl41
-rw-r--r--template/en/default/bug/field.html.tmpl242
-rw-r--r--template/en/default/bug/format_comment.txt.tmpl48
-rw-r--r--template/en/default/bug/knob.html.tmpl84
-rw-r--r--template/en/default/bug/link.html.tmpl51
-rw-r--r--template/en/default/bug/navigate.html.tmpl73
-rw-r--r--template/en/default/bug/process/bugmail.html.tmpl45
-rw-r--r--template/en/default/bug/process/confirm-duplicate.html.tmpl62
-rw-r--r--template/en/default/bug/process/header.html.tmpl32
-rw-r--r--template/en/default/bug/process/midair.html.tmpl97
-rw-r--r--template/en/default/bug/process/results.html.tmpl45
-rw-r--r--template/en/default/bug/process/verify-new-product.html.tmpl232
-rw-r--r--template/en/default/bug/show-header.html.tmpl58
-rw-r--r--template/en/default/bug/show-multiple.html.tmpl366
-rw-r--r--template/en/default/bug/show.html.tmpl36
-rw-r--r--template/en/default/bug/show.xml.tmpl161
-rw-r--r--template/en/default/bug/summarize-time.html.tmpl341
-rw-r--r--template/en/default/bug/time.html.tmpl34
-rw-r--r--template/en/default/config.js.tmpl137
-rw-r--r--template/en/default/config.rdf.tmpl288
-rw-r--r--template/en/default/email/bugmail-common.txt.tmpl25
-rw-r--r--template/en/default/email/bugmail-header.txt.tmpl36
-rw-r--r--template/en/default/email/bugmail.html.tmpl119
-rw-r--r--template/en/default/email/bugmail.txt.tmpl66
-rw-r--r--template/en/default/email/flagmail.txt.tmpl74
-rw-r--r--template/en/default/email/lockout.txt.tmpl25
-rw-r--r--template/en/default/email/sanitycheck.txt.tmpl23
-rw-r--r--template/en/default/email/sudo.txt.tmpl29
-rw-r--r--template/en/default/email/whine.txt.tmpl51
-rw-r--r--template/en/default/extensions/config.pm.tmpl26
-rw-r--r--template/en/default/extensions/extension.pm.tmpl31
-rw-r--r--template/en/default/extensions/hook-readme.txt.tmpl13
-rw-r--r--template/en/default/extensions/license.txt.tmpl18
-rw-r--r--template/en/default/extensions/name-readme.txt.tmpl24
-rw-r--r--template/en/default/extensions/util.pm.tmpl27
-rw-r--r--template/en/default/extensions/web-readme.txt.tmpl15
-rw-r--r--template/en/default/filterexceptions.pl468
-rw-r--r--template/en/default/flag/list.html.tmpl167
-rw-r--r--template/en/default/global/banner.html.tmpl12
-rw-r--r--template/en/default/global/calendar.js.tmpl33
-rw-r--r--template/en/default/global/choose-classification.html.tmpl54
-rw-r--r--template/en/default/global/choose-product.html.tmpl61
-rw-r--r--template/en/default/global/code-error.html.tmpl463
-rw-r--r--template/en/default/global/common-links.html.tmpl111
-rw-r--r--template/en/default/global/confirm-action.html.tmpl53
-rw-r--r--template/en/default/global/confirm-user-match.html.tmpl191
-rw-r--r--template/en/default/global/docslinks.html.tmpl45
-rw-r--r--template/en/default/global/field-descs.none.tmpl142
-rw-r--r--template/en/default/global/footer.html.tmpl36
-rw-r--r--template/en/default/global/header.html.tmpl313
-rw-r--r--template/en/default/global/hidden-fields.html.tmpl46
-rw-r--r--template/en/default/global/js-products.html.tmpl21
-rw-r--r--template/en/default/global/message.html.tmpl28
-rw-r--r--template/en/default/global/message.txt.tmpl12
-rw-r--r--template/en/default/global/messages.html.tmpl927
-rw-r--r--template/en/default/global/reason-descs.none.tmpl28
-rw-r--r--template/en/default/global/select-menu.html.tmpl52
-rw-r--r--template/en/default/global/setting-descs.none.tmpl49
-rw-r--r--template/en/default/global/site-navigation.html.tmpl75
-rw-r--r--template/en/default/global/tabs.html.tmpl42
-rw-r--r--template/en/default/global/textarea.html.tmpl55
-rw-r--r--template/en/default/global/useful-links.html.tmpl74
-rw-r--r--template/en/default/global/user-error.html.tmpl1964
-rw-r--r--template/en/default/global/user.html.tmpl26
-rw-r--r--template/en/default/global/userselect.html.tmpl99
-rw-r--r--template/en/default/global/value-descs.js.tmpl21
-rw-r--r--template/en/default/global/value-descs.none.tmpl24
-rw-r--r--template/en/default/global/variables.none.tmpl33
-rw-r--r--template/en/default/index.html.tmpl173
-rw-r--r--template/en/default/list/change-columns.html.tmpl131
-rw-r--r--template/en/default/list/edit-multiple.html.tmpl435
-rw-r--r--template/en/default/list/list-simple.html.tmpl32
-rw-r--r--template/en/default/list/list.atom.tmpl82
-rw-r--r--template/en/default/list/list.csv.tmpl38
-rw-r--r--template/en/default/list/list.html.tmpl342
-rw-r--r--template/en/default/list/list.ics.tmpl90
-rw-r--r--template/en/default/list/list.rdf.tmpl44
-rw-r--r--template/en/default/list/quips.html.tmpl159
-rw-r--r--template/en/default/list/server-push.html.tmpl30
-rw-r--r--template/en/default/list/table.html.tmpl250
-rw-r--r--template/en/default/pages/bug-writing.html.tmpl162
-rw-r--r--template/en/default/pages/bugzilla.dtd.tmpl162
-rw-r--r--template/en/default/pages/fields.html.tmpl227
-rw-r--r--template/en/default/pages/linked.html.tmpl42
-rw-r--r--template/en/default/pages/linkify.html.tmpl28
-rw-r--r--template/en/default/pages/quicksearch.html.tmpl325
-rw-r--r--template/en/default/pages/release-notes.html.tmpl2349
-rw-r--r--template/en/default/pages/release-notes3.html.tmpl3474
-rw-r--r--template/en/default/pages/sudo.html.tmpl55
-rw-r--r--template/en/default/reports/chart.csv.tmpl32
-rw-r--r--template/en/default/reports/chart.html.tmpl55
-rw-r--r--template/en/default/reports/chart.png.tmpl47
-rw-r--r--template/en/default/reports/components.html.tmpl94
-rw-r--r--template/en/default/reports/create-chart.html.tmpl258
-rw-r--r--template/en/default/reports/delete-series.html.tmpl47
-rw-r--r--template/en/default/reports/duplicates-simple.html.tmpl35
-rw-r--r--template/en/default/reports/duplicates-table.html.tmpl109
-rw-r--r--template/en/default/reports/duplicates.html.tmpl165
-rw-r--r--template/en/default/reports/edit-series.html.tmpl63
-rw-r--r--template/en/default/reports/keywords.html.tmpl71
-rw-r--r--template/en/default/reports/menu.html.tmpl75
-rw-r--r--template/en/default/reports/old-charts.html.tmpl62
-rw-r--r--template/en/default/reports/report-bar.png.tmpl48
-rw-r--r--template/en/default/reports/report-line.png.tmpl50
-rw-r--r--template/en/default/reports/report-pie.png.tmpl25
-rw-r--r--template/en/default/reports/report-simple.html.tmpl25
-rw-r--r--template/en/default/reports/report-table.csv.tmpl61
-rw-r--r--template/en/default/reports/report-table.html.tmpl256
-rw-r--r--template/en/default/reports/report.csv.tmpl12
-rw-r--r--template/en/default/reports/report.html.tmpl152
-rw-r--r--template/en/default/reports/series-common.html.tmpl107
-rw-r--r--template/en/default/reports/series.html.tmpl85
-rw-r--r--template/en/default/request/queue.html.tmpl263
-rw-r--r--template/en/default/search/boolean-charts.html.tmpl185
-rw-r--r--template/en/default/search/field.html.tmpl184
-rw-r--r--template/en/default/search/form.html.tmpl341
-rw-r--r--template/en/default/search/knob.html.tmpl74
-rw-r--r--template/en/default/search/search-advanced.html.tmpl55
-rw-r--r--template/en/default/search/search-create-series.html.tmpl57
-rw-r--r--template/en/default/search/search-plugin.xml.tmpl19
-rw-r--r--template/en/default/search/search-report-graph.html.tmpl128
-rw-r--r--template/en/default/search/search-report-select.html.tmpl29
-rw-r--r--template/en/default/search/search-report-table.html.tmpl81
-rw-r--r--template/en/default/search/search-specific.html.tmpl122
-rw-r--r--template/en/default/search/tabs.html.tmpl23
-rw-r--r--template/en/default/search/type-select.html.tmpl18
-rw-r--r--template/en/default/setup/strings.txt.pl417
-rw-r--r--template/en/default/welcome-admin.html.tmpl77
-rw-r--r--template/en/default/whine/mail.html.tmpl80
-rw-r--r--template/en/default/whine/mail.txt.tmpl61
-rw-r--r--template/en/default/whine/multipart-mime.txt.tmpl39
-rw-r--r--template/en/default/whine/schedule.html.tmpl432
-rwxr-xr-xtestagent.cgi16
-rwxr-xr-xtestserver.pl282
-rwxr-xr-xtoken.cgi340
-rwxr-xr-xuserprefs.cgi544
-rwxr-xr-xvotes.cgi36
-rwxr-xr-xwhine.pl694
-rwxr-xr-xwhineatnews.pl82
-rwxr-xr-xxmlrpc.cgi39
-rw-r--r--xt/README18
-rw-r--r--xt/lib/Bugzilla/Test/Search.pm987
-rw-r--r--xt/lib/Bugzilla/Test/Search/AndTest.pm52
-rw-r--r--xt/lib/Bugzilla/Test/Search/Constants.pm1198
-rw-r--r--xt/lib/Bugzilla/Test/Search/CustomTest.pm101
-rw-r--r--xt/lib/Bugzilla/Test/Search/FieldTest.pm617
-rw-r--r--xt/lib/Bugzilla/Test/Search/FieldTestNormal.pm104
-rw-r--r--xt/lib/Bugzilla/Test/Search/InjectionTest.pm77
-rw-r--r--xt/lib/Bugzilla/Test/Search/NotTest.pm61
-rw-r--r--xt/lib/Bugzilla/Test/Search/OperatorTest.pm103
-rw-r--r--xt/lib/Bugzilla/Test/Search/OrTest.pm141
-rw-r--r--xt/search.t82
807 files changed, 157003 insertions, 0 deletions
diff --git a/.bzrignore b/.bzrignore
new file mode 100644
index 0000000..7ab83e7
--- /dev/null
+++ b/.bzrignore
@@ -0,0 +1,32 @@
+.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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7ab83e7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+.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
diff --git a/.gitrev b/.gitrev
new file mode 100644
index 0000000..d70a019
--- /dev/null
+++ b/.gitrev
@@ -0,0 +1 @@
+d1a96d7f578241975efd41d87462bc776b3a505a \ No newline at end of file
diff --git a/Bugzilla.pm b/Bugzilla.pm
new file mode 100644
index 0000000..bb99764
--- /dev/null
+++ b/Bugzilla.pm
@@ -0,0 +1,977 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla;
+
+use strict;
+
+# We want any compile errors to get to the browser, if possible.
+BEGIN {
+ # This makes sure we're in a CGI.
+ if ($ENV{SERVER_SOFTWARE} && !$ENV{MOD_PERL}) {
+ require CGI::Carp;
+ CGI::Carp->import('fatalsToBrowser');
+ }
+}
+
+use Bugzilla::Config;
+use Bugzilla::Constants;
+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 include_languages);
+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;
+
+#####################################################################
+# Constants
+#####################################################################
+
+# Scripts that are not stopped by shutdownhtml being in effect.
+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 => 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
+#####################################################################
+
+# $::SIG{__DIE__} = i_am_cgi() ? \&CGI::Carp::confess : \&Carp::confess;
+
+# Note that this is a raw subroutine, not a method, so $class isn't available.
+sub init_page {
+ 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'} = '';
+ }
+
+ # 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.
+ if ($ENV{SERVER_SOFTWARE} && $ENV{SERVER_SOFTWARE} =~ /microsoft-iis/i) {
+ $SIG{__WARN__} = sub {
+ my ($msg) = @_;
+ my $datadir = bz_locations()->{'datadir'};
+ if (-w "$datadir/errorlog") {
+ my $warning_log = new IO::File(">>$datadir/errorlog");
+ print $warning_log $msg;
+ $warning_log->close();
+ }
+ };
+ }
+
+ 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.
+ #
+ # This code must go here. It cannot go anywhere in Bugzilla::CGI, because
+ # it uses Template, and that causes various dependency loops.
+ 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 (!i_am_cgi()
+ && grep { $_ eq $script } SHUTDOWNHTML_EXIT_SILENTLY)
+ {
+ exit;
+ }
+
+ # For security reasons, log out users when Bugzilla is down.
+ # Bugzilla->login() is required to catch the logincookie, if any.
+ my $user;
+ eval { $user = Bugzilla->login(LOGIN_OPTIONAL); };
+ if ($@) {
+ # The DB is not accessible. Use the default user object.
+ $user = Bugzilla->user;
+ $user->{settings} = {};
+ }
+ my $userid = $user->id;
+ Bugzilla->logout();
+
+ my $template = Bugzilla->template;
+ my $vars = {};
+ $vars->{'message'} = 'shutdown';
+ $vars->{'userid'} = $userid;
+ # Generate and return a message about the downtime, appropriately
+ # for if we're a command-line script or a CGI script.
+ my $extension;
+ if (i_am_cgi() && (!Bugzilla->cgi->param('ctype')
+ || Bugzilla->cgi->param('ctype') eq 'html')) {
+ $extension = 'html';
+ }
+ else {
+ $extension = 'txt';
+ }
+ 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);
+ print $t_output . "\n";
+ exit;
+ }
+}
+
+#####################################################################
+# Subroutines and Methods
+#####################################################################
+
+sub template {
+ my $class = shift;
+ $class->request_cache->{template} ||= Bugzilla::Template->create();
+ return $class->request_cache->{template};
+}
+
+sub template_inner {
+ my ($class, $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(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 {
+ return $_[0]->process_cache->{localconfig} ||= read_localconfig();
+}
+
+sub params {
+ my $class = shift;
+ $class->request_cache->{params} ||= Bugzilla::Config::read_param_file();
+ return $class->request_cache->{params};
+}
+
+sub user {
+ my $class = shift;
+ $class->request_cache->{user} ||= new Bugzilla::User;
+ return $class->request_cache->{user};
+}
+
+sub set_user {
+ my ($class, $user) = @_;
+ $class->request_cache->{user} = $user;
+}
+
+sub sudoer {
+ my $class = shift;
+ return $class->request_cache->{sudoer};
+}
+
+sub sudo_request {
+ my ($class, $new_user, $new_sudoer) = @_;
+ $class->request_cache->{user} = $new_user;
+ $class->request_cache->{sudoer} = $new_sudoer;
+ # 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) = @_;
+
+ return $class->user if $class->user->id;
+
+ 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.
+ # We must now check to see if an sudo session is in progress.
+ # For a session to be in progress, the following must be true:
+ # 1: There must be a logged in user
+ # 2: That user must be in the 'bz_sudoer' group
+ # 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 $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.
+ }
+ 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);
+ }
+
+ if ($class->sudoer) {
+ $class->sudoer->update_last_seen_date();
+ } else {
+ $class->user->update_last_seen_date();
+ }
+
+ return $class->user;
+}
+
+sub logout {
+ my ($class, $option) = @_;
+
+ # If we're not logged in, go away
+ return unless $class->user->id;
+
+ $option = LOGOUT_CURRENT unless defined $option;
+ Bugzilla::Auth::Persist::Cookie->logout({type => $option});
+ $class->logout_request() unless $option eq LOGOUT_KEEP_CURRENT;
+}
+
+sub logout_user {
+ my ($class, $user) = @_;
+ # When we're logging out another user we leave cookies alone, and
+ # therefore avoid calling Bugzilla->logout() directly.
+ Bugzilla::Auth::Persist::Cookie->logout({user => $user});
+}
+
+# just a compatibility front-end to logout_user that gets a user by id
+sub logout_user_by_id {
+ my ($class, $id) = @_;
+ my $user = new Bugzilla::User($id);
+ $class->logout_user($user);
+}
+
+# hack that invalidates credentials for a single request
+sub logout_request {
+ my $class = shift;
+ delete $class->request_cache->{user};
+ delete $class->request_cache->{sudoer};
+ # We can't delete from $cgi->cookie, so logincookie data will remain
+ # 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->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 Bugzilla::Install::Util::supported_languages();
+}
+
+sub current_language {
+ return $_[0]->request_cache->{current_language} ||= (include_languages())[0];
+}
+
+sub error_mode {
+ my ($class, $newval) = @_;
+ if (defined $newval) {
+ $class->request_cache->{error_mode} = $newval;
+ }
+
+ # XXX - Once we require Perl 5.10.1, this test can be replaced by //.
+ if (exists $class->request_cache->{error_mode}) {
+ return $class->request_cache->{error_mode};
+ }
+ else {
+ return (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 {
+ my ($class, $newval) = @_;
+ if (defined $newval) {
+ if ($newval == USAGE_MODE_BROWSER) {
+ $class->error_mode(ERROR_MODE_WEBPAGE);
+ }
+ elsif ($newval == USAGE_MODE_CMDLINE) {
+ $class->error_mode(ERROR_MODE_DIE);
+ }
+ 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;
+ }
+
+ # XXX - Once we require Perl 5.10.1, this test can be replaced by //.
+ if (exists $class->request_cache->{usage_mode}) {
+ return $class->request_cache->{usage_mode};
+ }
+ else {
+ return (i_am_cgi()? USAGE_MODE_BROWSER : USAGE_MODE_CMDLINE);
+ }
+}
+
+sub installation_mode {
+ my ($class, $newval) = @_;
+ ($class->request_cache->{installation_mode} = $newval) if defined $newval;
+ return $class->request_cache->{installation_mode}
+ || INSTALLATION_MODE_INTERACTIVE;
+}
+
+sub installation_answers {
+ my ($class, $filename) = @_;
+ if ($filename) {
+ my $s = new Safe;
+ $s->rdo($filename);
+
+ die "Error reading $filename: $!" if $!;
+ die "Error evaluating $filename: $@" if $@;
+
+ # Now read the param back out from the sandbox
+ $class->request_cache->{installation_answers} = $s->varglob('answer');
+ }
+ return $class->request_cache->{installation_answers} || {};
+}
+
+sub switch_to_shadow_db {
+ my $class = shift;
+
+ if (!$class->request_cache->{dbh_shadow}) {
+ if ($class->params->{'shadowdb'}) {
+ $class->request_cache->{dbh_shadow} = Bugzilla::DB::connect_shadow();
+ } else {
+ $class->request_cache->{dbh_shadow} = $class->dbh_main;
+ }
+ }
+
+ $class->request_cache->{dbh} = $class->request_cache->{dbh_shadow};
+ # we have to return $class->dbh instead of {dbh} as
+ # {dbh_shadow} may be undefined if no shadow DB is used
+ # and no connection to the main DB has been established yet.
+ return $class->dbh;
+}
+
+sub switch_to_main_db {
+ my $class = shift;
+
+ $class->request_cache->{dbh} = $class->dbh_main;
+ return $class->dbh_main;
+}
+
+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
+ : [sort { $a->sortkey <=> $b->sortkey || $a->name cmp $b->name } values %requested];
+}
+
+sub active_custom_fields {
+ my $class = shift;
+ if (!exists $class->request_cache->{active_custom_fields}) {
+ $class->request_cache->{active_custom_fields} =
+ Bugzilla::Field->match({ custom => 1, obsolete => 0 });
+ }
+ return @{$class->request_cache->{active_custom_fields}};
+}
+
+sub has_flags {
+ my $class = shift;
+
+ if (!defined $class->request_cache->{has_flags}) {
+ $class->request_cache->{has_flags} = Bugzilla::Flag->any_exist;
+ }
+ return $class->request_cache->{has_flags};
+}
+
+sub local_timezone {
+ my $class = shift;
+
+ if (!defined $class->process_cache->{local_timezone}) {
+ $class->process_cache->{local_timezone} =
+ DateTime::TimeZone->new(name => 'local');
+ }
+ return $class->process_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;
+ # 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;
+}
+
+sub clear_request_cache {
+ $_request_cache = {};
+ if ($ENV{MOD_PERL}) {
+ require Apache2::RequestUtil;
+ my $request = eval { Apache2::RequestUtil->request };
+ if ($request) {
+ my $pnotes = $request->pnotes;
+ delete @$pnotes{(keys %$pnotes)};
+ }
+ }
+}
+
+# This is a per-process cache. Under mod_cgi it's identical to the
+# request_cache. When using mod_perl, items in this cache live until the
+# worker process is terminated.
+our $_process_cache = {};
+
+sub process_cache {
+ return $_process_cache;
+}
+
+# Private methods
+
+# Per-process cleanup. Note that this is a plain subroutine, not a method,
+# so we don't have $class available.
+sub _cleanup {
+ my $main = Bugzilla->request_cache->{dbh_main};
+ my $shadow = Bugzilla->request_cache->{dbh_shadow};
+ foreach my $dbh ($main, $shadow) {
+ next if !$dbh;
+ $dbh->bz_rollback_transaction() if $dbh->bz_in_transaction;
+ $dbh->disconnect;
+ }
+ clear_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 {
+ # Bugzilla.pm cannot compile in mod_perl.pl if this runs.
+ _cleanup() unless $ENV{MOD_PERL};
+}
+
+init_page() if !$ENV{MOD_PERL};
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla - Semi-persistent collection of various objects used by scripts
+and modules
+
+=head1 SYNOPSIS
+
+ use Bugzilla;
+
+ sub someModulesSub {
+ Bugzilla->dbh->prepare(...);
+ Bugzilla->template->process(...);
+ }
+
+=head1 DESCRIPTION
+
+Several Bugzilla 'things' are used by a variety of modules and scripts. This
+includes database handles, template objects, and so on.
+
+This module is a singleton intended as a central place to store these objects.
+This approach has several advantages:
+
+=over 4
+
+=item *
+
+They're not global variables, so we don't have issues with them staying around
+with mod_perl
+
+=item *
+
+Everything is in one central place, so it's easy to access, modify, and maintain
+
+=item *
+
+Code in modules can get access to these objects without having to have them
+all passed from the caller, and the caller's caller, and....
+
+=item *
+
+We can reuse objects across requests using mod_perl where appropriate (eg
+templates), whilst destroying those which are only valid for a single request
+(such as the current user)
+
+=back
+
+Note that items accessible via this object are demand-loaded when requested.
+
+For something to be added to this object, it should either be able to benefit
+from persistence when run under mod_perl (such as the a C<template> object),
+or should be something which is globally required by a large ammount of code
+(such as the current C<user> object).
+
+=head1 METHODS
+
+Note that all C<Bugzilla> functionality is method based; use C<Bugzilla-E<gt>dbh>
+rather than C<Bugzilla::dbh>. Nothing cares about this now, but don't rely on
+that.
+
+=over 4
+
+=item C<template>
+
+The current C<Template> object, to be used for output
+
+=item C<template_inner>
+
+If you ever need a L<Bugzilla::Template> object while you're already
+processing a template, use this. Also use it if you want to specify
+the language to use. If no argument is passed, it uses the last
+language set. If the argument is "" (empty string), the language is
+reset to the current one (the one used by Bugzilla->template).
+
+=item C<cgi>
+
+The current C<cgi> object. Note that modules should B<not> be using this in
+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>
+
+Default C<Bugzilla::User> object if there is no currently logged in user or
+if the login code has not yet been run. If an sudo session is in progress,
+the C<Bugzilla::User> corresponding to the person who is being impersonated.
+If no session is in progress, the current C<Bugzilla::User>.
+
+=item C<set_user>
+
+Allows you to directly set what L</user> will return. You can use this
+if you want to bypass L</login> for some reason and directly "log in"
+a specific L<Bugzilla::User>. Be careful with it, though!
+
+=item C<sudoer>
+
+C<undef> if there is no currently logged in user, the currently logged in user
+is not in the I<sudoer> group, or there is no session in progress. If an sudo
+session is in progress, returns the C<Bugzilla::User> object corresponding to
+the person who logged in and initiated the session. If no session is in
+progress, returns the C<Bugzilla::User> object corresponding to the currently
+logged in user.
+
+=item C<sudo_request>
+This begins an sudo session for the current request. It is meant to be
+used when a session has just started. For normal use, sudo access should
+normally be set at login time.
+
+=item C<login>
+
+Logs in a user, returning a C<Bugzilla::User> object, or C<undef> if there is
+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
+cookies. Three options are available from
+L<Bugzilla::Constants|Bugzilla::Constants>: LOGOUT_CURRENT (the
+default), LOGOUT_ALL or LOGOUT_KEEP_CURRENT.
+
+=item C<logout_user($user)>
+
+Logs out the specified user (invalidating all his sessions), taking a
+Bugzilla::User instance.
+
+=item C<logout_by_id($id)>
+
+Logs out the user with the id specified. This is a compatibility
+function to be used in callsites where there is only a userid and no
+Bugzilla::User instance.
+
+=item C<logout_request>
+
+Essentially, causes calls to C<Bugzilla-E<gt>user> to return C<undef>. This has the
+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.
+
+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)>
+or C<Bugzilla->error_mode(Bugzilla::Constants::ERROR_MODE_DIE_SOAP_FAULT)> to
+change this flag's default of C<Bugzilla::Constants::ERROR_MODE_WEBPAGE> and to
+indicate that errors should be passed to error mode specific error handlers
+rather than being sent to a browser and finished with an exit().
+
+This is useful, for example, to keep C<eval> blocks from producing wild HTML
+on errors, making it easier for you to catch them.
+(Remember to reset the error mode to its previous value afterwards, though.)
+
+C<Bugzilla->error_mode> will return the current state of this flag.
+
+Note that C<Bugzilla->error_mode> is being called by C<Bugzilla->usage_mode> on
+usage mode changes.
+
+=item C<usage_mode>
+
+Call either C<Bugzilla->usage_mode(Bugzilla::Constants::USAGE_MODE_CMDLINE)>
+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.
+
+C<Bugzilla->usage_mode> will return the current state of this flag.
+
+=item C<installation_mode>
+
+Determines whether or not installation should be silent. See
+L<Bugzilla::Constants> for the C<INSTALLATION_MODE> constants.
+
+=item C<installation_answers>
+
+Returns a hashref representing any "answers" file passed to F<checksetup.pl>,
+used to automatically answer or skip prompts.
+
+=item C<dbh>
+
+The current database handle. See L<DBI>.
+
+=item C<dbh_main>
+
+The main database handle. See L<DBI>.
+
+=item C<languages>
+
+Currently installed languages.
+Returns a reference to a list of RFC 1766 language tags of installed languages.
+
+=item C<current_language>
+
+The currently active language.
+
+=item C<switch_to_shadow_db>
+
+Switch from using the main database to using the shadow database.
+
+=item C<switch_to_main_db>
+
+Change the database object to refer to the main database.
+
+=item C<params>
+
+The current Parameters of Bugzilla, as a hashref. If C<data/params>
+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<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>
+
+Tells you whether or not a specific feature is enabled. For names
+of features, see C<OPTIONAL_MODULES> in C<Bugzilla::Install::Requirements>.
+
+=back
diff --git a/Bugzilla/Attachment.pm b/Bugzilla/Attachment.pm
new file mode 100644
index 0000000..380ef3d
--- /dev/null
+++ b/Bugzilla/Attachment.pm
@@ -0,0 +1,991 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+
+package Bugzilla::Attachment;
+
+=head1 NAME
+
+Bugzilla::Attachment - Bugzilla attachment class.
+
+=head1 SYNOPSIS
+
+ use Bugzilla::Attachment;
+
+ # Get the attachment with the given ID.
+ my $attachment = new Bugzilla::Attachment($attach_id);
+
+ # Get the attachments with the given IDs.
+ my $attachments = Bugzilla::Attachment->new_from_list($attach_ids);
+
+=head1 DESCRIPTION
+
+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::Constants;
+use Bugzilla::Error;
+use Bugzilla::Flag;
+use Bugzilla::User;
+use Bugzilla::Util;
+use Bugzilla::Field;
+use Bugzilla::Hook;
+
+use File::Copy;
+use List::Util qw(max);
+
+use base qw(Bugzilla::Object);
+
+###############################
+#### Initialization ####
+###############################
+
+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;
+
+sub DB_COLUMNS {
+ my $dbh = Bugzilla->dbh;
+
+ 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';
+}
+
+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
+
+=head2 Instance Properties
+
+=over
+
+=item C<bug_id>
+
+the ID of the bug to which the attachment is attached
+
+=back
+
+=cut
+
+sub bug_id {
+ my $self = shift;
+ return $self->{bug_id};
+}
+
+=over
+
+=item C<bug>
+
+the bug object to which the attachment is attached
+
+=back
+
+=cut
+
+sub bug {
+ my $self = shift;
+
+ require Bugzilla::Bug;
+ $self->{bug} ||= Bugzilla::Bug->new($self->bug_id);
+ return $self->{bug};
+}
+
+=over
+
+=item C<description>
+
+user-provided text describing the attachment
+
+=back
+
+=cut
+
+sub description {
+ my $self = shift;
+ return $self->{description};
+}
+
+=over
+
+=item C<contenttype>
+
+the attachment's MIME media type
+
+=back
+
+=cut
+
+sub contenttype {
+ my $self = shift;
+ return $self->{mimetype};
+}
+
+=over
+
+=item C<attacher>
+
+the user who attached the attachment
+
+=back
+
+=cut
+
+sub attacher {
+ my $self = shift;
+ return $self->{attacher} if exists $self->{attacher};
+ $self->{attacher} = new Bugzilla::User($self->{submitter_id});
+ return $self->{attacher};
+}
+
+=over
+
+=item C<attached>
+
+the date and time on which the attacher attached the attachment
+
+=back
+
+=cut
+
+sub attached {
+ my $self = shift;
+ return $self->{creation_ts};
+}
+
+=over
+
+=item C<modification_time>
+
+the date and time on which the attachment was last modified.
+
+=back
+
+=cut
+
+sub modification_time {
+ my $self = shift;
+ return $self->{modification_time};
+}
+
+=over
+
+=item C<filename>
+
+the name of the file the attacher attached
+
+=back
+
+=cut
+
+sub filename {
+ my $self = shift;
+ return $self->{filename};
+}
+
+=over
+
+=item C<ispatch>
+
+whether or not the attachment is a patch
+
+=back
+
+=cut
+
+sub ispatch {
+ my $self = shift;
+ return $self->{ispatch};
+}
+
+=over
+
+=item C<isobsolete>
+
+whether or not the attachment is obsolete
+
+=back
+
+=cut
+
+sub isobsolete {
+ my $self = shift;
+ return $self->{isobsolete};
+}
+
+=over
+
+=item C<isprivate>
+
+whether or not the attachment is private
+
+=back
+
+=cut
+
+sub isprivate {
+ my $self = shift;
+ return $self->{isprivate};
+}
+
+=over
+
+=item C<is_viewable>
+
+Returns 1 if the attachment has a content-type viewable in this browser.
+Note that we don't use $cgi->Accept()'s ability to check if a content-type
+matches, because this will return a value even if it's matched by the generic
+*/* which most browsers add to the end of their Accept: headers.
+
+=back
+
+=cut
+
+sub is_viewable {
+ my $self = shift;
+ my $contenttype = $self->contenttype;
+ my $cgi = Bugzilla->cgi;
+
+ # We assume we can view all text and image types.
+ return 1 if ($contenttype =~ /^(text|image)\//);
+
+ # Mozilla can view XUL. Note the trailing slash on the Gecko detection to
+ # avoid sending XUL to Safari.
+ return 1 if (($contenttype =~ /^application\/vnd\.mozilla\./)
+ && ($cgi->user_agent() =~ /Gecko\//));
+
+ # If it's not one of the above types, we check the Accept: header for any
+ # types mentioned explicitly.
+ my $accept = join(",", $cgi->Accept());
+ return 1 if ($accept =~ /^(.*,)?\Q$contenttype\E(,.*)?$/);
+
+ return 0;
+}
+
+=over
+
+=item C<data>
+
+the content of the attachment
+
+=back
+
+=cut
+
+sub data {
+ my $self = shift;
+ return $self->{data} if exists $self->{data};
+
+ # First try to get the attachment data from the database.
+ ($self->{data}) = Bugzilla->dbh->selectrow_array("SELECT thedata
+ FROM attach_data
+ WHERE id = ?",
+ undef,
+ $self->id);
+
+ # If there's no attachment data in the database, the attachment is stored
+ # in a local file, so retrieve it from there.
+ if (length($self->{data}) == 0) {
+ if (open(AH, $self->_get_local_filename())) {
+ local $/;
+ binmode AH;
+ $self->{data} = <AH>;
+ close(AH);
+ }
+ }
+
+ return $self->{data};
+}
+
+=over
+
+=item C<datasize>
+
+the length (in bytes) of the attachment content
+
+=back
+
+=cut
+
+# datasize is a property of the data itself, and it's unclear whether we should
+# expose it at all, since you can easily derive it from the data itself: in TT,
+# attachment.data.size; in Perl, length($attachment->{data}). But perhaps
+# it makes sense for performance reasons, since accessing the data forces it
+# to get retrieved from the database/filesystem and loaded into memory,
+# while datasize avoids loading the attachment into memory, calling SQL's
+# LENGTH() function or stat()ing the file instead. I've left it in for now.
+
+sub datasize {
+ my $self = shift;
+ return $self->{datasize} if exists $self->{datasize};
+
+ # If we have already retrieved the data, return its size.
+ return length($self->{data}) if exists $self->{data};
+
+ $self->{datasize} =
+ Bugzilla->dbh->selectrow_array("SELECT LENGTH(thedata)
+ FROM attach_data
+ WHERE id = ?",
+ 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,
+ # or the attachment has been deleted.
+ unless ($self->{datasize}) {
+ if (open(AH, $self->_get_local_filename())) {
+ binmode AH;
+ $self->{datasize} = (stat(AH))[7];
+ close(AH);
+ }
+ }
+
+ 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>
+
+flags that have been set on the attachment
+
+=back
+
+=cut
+
+sub flags {
+ my $self = shift;
+
+ # Don't cache it as it must be in sync with ->flag_types.
+ $self->{flags} = [map { @{$_->{flags}} } @{$self->flag_types}];
+ return $self->{flags};
+}
+
+=over
+
+=item C<flag_types>
+
+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;
+ 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};
+}
+
+###############################
+#### 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}};
+ }
+ }
+}
+
+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);
+
+ # $ENV{HOME} must be defined when using File::MimeInfo::Magic,
+ # see https://rt.cpan.org/Public/Bug/Display.html?id=41744.
+ local $ENV{HOME} = $ENV{HOME} || File::Spec->rootdir();
+
+ # If we have autodetected application/octet-stream from the Content-Type
+ # header, let's have a better go using a sniffer if available.
+ if (defined Bugzilla->input_params->{contenttypemethod}
+ && Bugzilla->input_params->{contenttypemethod} eq 'autodetect'
+ && $content_type eq 'application/octet-stream'
+ && Bugzilla->feature('typesniffer'))
+ {
+ import File::MimeInfo::Magic qw(mimetype);
+ require IO::Scalar;
+
+ # data is either a filehandle, or the data itself.
+ my $fh = $params->{data};
+ if (!ref($fh)) {
+ $fh = new IO::Scalar \$fh;
+ }
+ elsif (!$fh->isa('IO::Handle')) {
+ # CGI.pm sends us an Fh that isn't actually an IO::Handle, but
+ # has a method for getting an actual handle out of it.
+ $fh = $fh->handle;
+ # ->handle returns an literal IO::Handle, even though the
+ # underlying object is a file. So we rebless it to be a proper
+ # IO::File object so that we can call ->seek on it and so on.
+ # Just in case CGI.pm fixes this some day, we check ->isa first.
+ if (!$fh->isa('IO::File')) {
+ bless $fh, 'IO::File';
+ }
+ }
+
+ my $mimetype = mimetype($fh);
+ $fh->seek(0, 0);
+ $content_type = $mimetype if $mimetype;
+ }
+
+ # Make sure patches are viewable in the browser
+ if (!ref($invocant)
+ && defined Bugzilla->input_params->{contenttypemethod}
+ && Bugzilla->input_params->{contenttypemethod} eq 'autodetect'
+ && $content_type =~ m{text/x-(?:diff|patch)})
+ {
+ $params->{ispatch} = 1;
+ $content_type = 'text/plain';
+ }
+
+ 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
+ # mess up file names with slashes in them, but them's the breaks. We only
+ # use this as a hint to users downloading attachments anyway, so it's not
+ # a big deal if it munges incorrectly occasionally.
+ $filename =~ s/^.*[\/\\]//;
+
+ # Truncate the filename to MAX_ATTACH_FILENAME_LENGTH characters, counting
+ # from the end of the string to make sure we keep the filename extension.
+ $filename = substr($filename,
+ -&MAX_ATTACH_FILENAME_LENGTH,
+ MAX_ATTACH_FILENAME_LENGTH);
+ trick_taint($filename);
+
+ return $filename;
+}
+
+sub _check_is_private {
+ my ($invocant, $is_private) = @_;
+
+ $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 $is_private;
+}
+
+=pod
+
+=head2 Class Methods
+
+=over
+
+=item C<get_attachments_by_bug($bug)>
+
+Description: retrieves and returns the attachments the currently logged in
+ user can view for the given bug.
+
+Params: C<$bug> - Bugzilla::Bug object - the bug for which
+ to retrieve and return attachments.
+
+Returns: a reference to an array of attachment objects.
+
+=cut
+
+sub get_attachments_by_bug {
+ my ($class, $bug, $vars) = @_;
+ my $user = Bugzilla->user;
+ my $dbh = Bugzilla->dbh;
+
+ # By default, private attachments are not accessible, unless the user
+ # is in the insider group or submitted the attachment.
+ my $and_restriction = '';
+ my @values = ($bug->id);
+
+ unless ($user->is_insider) {
+ $and_restriction = 'AND (isprivate = 0 OR submitter_id = ?)';
+ push(@values, $user->id);
+ }
+
+ my $attach_ids = $dbh->selectcol_arrayref("SELECT attach_id FROM attachments
+ WHERE bug_id = ? $and_restriction",
+ undef, @values);
+
+ my $attachments = Bugzilla::Attachment->new_from_list($attach_ids);
+ $_->{bug} = $bug foreach @$attachments;
+
+ # 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.
+ # We also load all attachers at once for the same reason.
+ if ($vars->{preload}) {
+ # Preload flags.
+ $_->{flags} = [] foreach @$attachments;
+ my %att = map { $_->id => $_ } @$attachments;
+
+ my $flags = Bugzilla::Flag->match({ bug_id => $bug->id,
+ target_type => 'attachment' });
+
+ # Exclude flags for private attachments you cannot see.
+ @$flags = grep {exists $att{$_->attach_id}} @$flags;
+
+ push(@{$att{$_->attach_id}->{flags}}, $_) foreach @$flags;
+ $attachments = [sort {$a->id <=> $b->id} values %att];
+
+ # Preload attachers.
+ my %user_ids = map { $_->{submitter_id} => 1 } @$attachments;
+ my $users = Bugzilla::User->new_from_list([keys %user_ids]);
+ my %user_map = map { $_->id => $_ } @$users;
+ foreach my $attachment (@$attachments) {
+ $attachment->{attacher} = $user_map{$attachment->{submitter_id}};
+ }
+ }
+ return $attachments;
+}
+
+=pod
+
+=item C<validate_can_edit($attachment, $product_id)>
+
+Description: validates if the user is allowed to view and edit the attachment.
+ Only the submitter or someone with editbugs privs can edit it.
+ Only the submitter and users in the insider group can view
+ private attachments.
+
+Params: $attachment - the attachment object being edited.
+ $product_id - the product ID the attachment belongs to.
+
+Returns: 1 on success, 0 otherwise.
+
+=cut
+
+sub validate_can_edit {
+ my ($attachment, $product_id) = @_;
+ my $user = Bugzilla->user;
+
+ # The submitter can edit their attachments.
+ return ($attachment->attacher->id == $user->id
+ || ((!$attachment->isprivate || $user->is_insider)
+ && $user->in_group('editbugs', $product_id))) ? 1 : 0;
+}
+
+=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.
+ Moreover, a user cannot mark an attachment as obsolete if
+ 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: The list of attachment objects to mark as obsolete.
+ Else an error is thrown.
+
+=cut
+
+sub validate_obsolete {
+ 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 (@$list) {
+ my $vars = {};
+ $vars->{'attach_id'} = $attachid;
+
+ detaint_natural($attachid)
+ || ThrowUserError('invalid_attach_id', $vars);
+
+ # Make sure the attachment exists in the database.
+ 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)
+ || ThrowUserError('illegal_attachment_edit', { attach_id => $attachment->id });
+
+ if ($attachment->bug_id != $bug->bug_id) {
+ $vars->{'my_bug_id'} = $bug->bug_id;
+ ThrowUserError('mismatched_bug_ids_on_obsolete', $vars);
+ }
+
+ next if $attachment->isobsolete;
+
+ push(@obsolete_attachments, $attachment);
+ }
+ return @obsolete_attachments;
+}
+
+###############################
+#### Constructors #####
+###############################
+
+=pod
+
+=item C<create>
+
+Description: inserts an attachment into the given bug.
+
+Params: takes a hashref with the following keys:
+ C<bug> - Bugzilla::Bug object - the bug for which to insert
+ the 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 create {
+ my $class = shift;
+ my $dbh = Bugzilla->dbh;
+
+ $class->check_required_create_fields(@_);
+ my $params = $class->run_create_validators(@_);
+
+ # 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};
+
+ my $attachment = $class->insert_create_data($params);
+ my $attachid = $attachment->id;
+
+ # 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 $hash = ($attachid % 100) + 100;
+ $hash =~ s/.*(\d\d)$/group.$1/;
+ mkdir "$attachdir/$hash", 0770;
+ chmod 0770, "$attachdir/$hash";
+ if (ref $data) {
+ copy($data, "$attachdir/$hash/attachment.$attachid");
+ close $data;
+ }
+ 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;
+ }
+
+ 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 || ThrowUserError('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)');
+
+ my ($changes, $old_self) = $self->SUPER::update(@_);
+
+ my ($removed, $added) = Bugzilla::Flag->update_flags($self, $old_self, $timestamp);
+ if ($removed || $added) {
+ $changes->{'flagtypes.name'} = [$removed, $added];
+ }
+
+ # 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]);
+ }
+
+ 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 $changes;
+}
+
+=pod
+
+=item C<remove_from_db()>
+
+Description: removes an attachment from the DB.
+
+Params: none
+
+Returns: nothing
+
+=back
+
+=cut
+
+sub remove_from_db {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+
+ $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 = ?, isobsolete = ?
+ WHERE attach_id = ?', undef, ('text/plain', 0, 1, $self->id));
+ $dbh->bz_commit_transaction();
+
+ my $filename = $self->_get_local_filename;
+ if (-e $filename) {
+ unlink $filename or warn "Couldn't unlink $filename: $!";
+ }
+}
+
+###############################
+#### 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;
+ my $method = $cgi->param('contenttypemethod') || '';
+
+ if ($method eq 'list') {
+ # The user selected a content type from the list, so use their
+ # selection.
+ $content_type = $cgi->param('contenttypeselection');
+ }
+ elsif ($method eq 'manual') {
+ # The user entered a content type manually, so use their entry.
+ $content_type = $cgi->param('contenttypeentry');
+ }
+ else {
+ 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");
+
+ # 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';
+ }
+ }
+ return $content_type;
+}
+
+
+1;
diff --git a/Bugzilla/Attachment/PatchReader.pm b/Bugzilla/Attachment/PatchReader.pm
new file mode 100644
index 0000000..4026ba7
--- /dev/null
+++ b/Bugzilla/Attachment/PatchReader.pm
@@ -0,0 +1,286 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+
+package Bugzilla::Attachment::PatchReader;
+
+use Bugzilla::Error;
+use Bugzilla::Attachment;
+use Bugzilla::Util;
+
+sub process_diff {
+ my ($attachment, $format, $context) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $cgi = Bugzilla->cgi;
+ my $lc = Bugzilla->localconfig;
+ my $vars = {};
+
+ my ($reader, $last_reader) = setup_patch_readers(undef, $context);
+
+ if ($format eq 'raw') {
+ require PatchReader::DiffPrinter::raw;
+ $last_reader->sends_data_to(new PatchReader::DiffPrinter::raw());
+ # Actually print out the patch.
+ print $cgi->header(-type => 'text/plain',
+ -expires => '+3M');
+ disable_utf8();
+ $reader->iterate_string('Attachment ' . $attachment->id, $attachment->data);
+ }
+ else {
+ my @other_patches = ();
+ if ($lc->{interdiffbin} && $lc->{diffpath}) {
+ # Get the list of attachments that the user can view in this bug.
+ my @attachments =
+ @{Bugzilla::Attachment->get_attachments_by_bug($attachment->bug)};
+ # Extract patches only.
+ @attachments = grep {$_->ispatch == 1} @attachments;
+ # We want them sorted from newer to older.
+ @attachments = sort { $b->id <=> $a->id } @attachments;
+
+ # Ignore the current patch, but select the one right before it
+ # chronologically.
+ my $select_next_patch = 0;
+ foreach my $attach (@attachments) {
+ if ($attach->id == $attachment->id) {
+ $select_next_patch = 1;
+ }
+ else {
+ push(@other_patches, { 'id' => $attach->id,
+ 'desc' => $attach->description,
+ 'selected' => $select_next_patch });
+ $select_next_patch = 0;
+ }
+ }
+ }
+
+ $vars->{'bugid'} = $attachment->bug_id;
+ $vars->{'attachid'} = $attachment->id;
+ $vars->{'description'} = $attachment->description;
+ $vars->{'other_patches'} = \@other_patches;
+
+ setup_template_patch_reader($last_reader, $format, $context, $vars);
+ # The patch is going to be displayed in a HTML page and if the utf8
+ # param is enabled, we have to encode attachment data as utf8.
+ if (Bugzilla->params->{'utf8'}) {
+ $attachment->data; # Populate ->{data}
+ utf8::decode($attachment->{data});
+ }
+ $reader->iterate_string('Attachment ' . $attachment->id, $attachment->data);
+ }
+}
+
+sub process_interdiff {
+ my ($old_attachment, $new_attachment, $format, $context) = @_;
+ my $cgi = Bugzilla->cgi;
+ my $lc = Bugzilla->localconfig;
+ my $vars = {};
+
+ # Encode attachment data as utf8 if it's going to be displayed in a HTML
+ # page using the UTF-8 encoding.
+ if ($format ne 'raw' && Bugzilla->params->{'utf8'}) {
+ $old_attachment->data; # Populate ->{data}
+ utf8::decode($old_attachment->{data});
+ $new_attachment->data; # Populate ->{data}
+ utf8::decode($new_attachment->{data});
+ }
+
+ # Get old patch data.
+ my ($old_filename, $old_file_list) = get_unified_diff($old_attachment, $format);
+ # Get new patch data.
+ my ($new_filename, $new_file_list) = get_unified_diff($new_attachment, $format);
+
+ my $warning = warn_if_interdiff_might_fail($old_file_list, $new_file_list);
+
+ # Send through interdiff, send output directly to template.
+ # Must hack path so that interdiff will work.
+ $ENV{'PATH'} = $lc->{diffpath};
+ open my $interdiff_fh, "$lc->{interdiffbin} $old_filename $new_filename|";
+ binmode $interdiff_fh;
+ my ($reader, $last_reader) = setup_patch_readers("", $context);
+
+ if ($format eq 'raw') {
+ require PatchReader::DiffPrinter::raw;
+ $last_reader->sends_data_to(new PatchReader::DiffPrinter::raw());
+ # Actually print out the patch.
+ print $cgi->header(-type => 'text/plain',
+ -expires => '+3M');
+ disable_utf8();
+ }
+ else {
+ # In case the HTML page is displayed with the UTF-8 encoding.
+ binmode $interdiff_fh, ':utf8' if Bugzilla->params->{'utf8'};
+
+ $vars->{'warning'} = $warning if $warning;
+ $vars->{'bugid'} = $new_attachment->bug_id;
+ $vars->{'oldid'} = $old_attachment->id;
+ $vars->{'old_desc'} = $old_attachment->description;
+ $vars->{'newid'} = $new_attachment->id;
+ $vars->{'new_desc'} = $new_attachment->description;
+
+ setup_template_patch_reader($last_reader, $format, $context, $vars);
+ }
+ $reader->iterate_fh($interdiff_fh, 'interdiff #' . $old_attachment->id .
+ ' #' . $new_attachment->id);
+ close $interdiff_fh;
+ $ENV{'PATH'} = '';
+
+ # Delete temporary files.
+ unlink($old_filename) or warn "Could not unlink $old_filename: $!";
+ unlink($new_filename) or warn "Could not unlink $new_filename: $!";
+}
+
+######################
+# Internal routines
+######################
+
+sub get_unified_diff {
+ my ($attachment, $format) = @_;
+
+ # Bring in the modules we need.
+ require PatchReader::Raw;
+ require PatchReader::FixPatchRoot;
+ require PatchReader::DiffPrinter::raw;
+ require PatchReader::PatchInfoGrabber;
+ require File::Temp;
+
+ $attachment->ispatch
+ || ThrowCodeError('must_be_patch', { 'attach_id' => $attachment->id });
+
+ # Reads in the patch, converting to unified diff in a temp file.
+ my $reader = new PatchReader::Raw;
+ my $last_reader = $reader;
+
+ # Fixes patch root (makes canonical if possible).
+ if (Bugzilla->params->{'cvsroot'}) {
+ my $fix_patch_root =
+ new PatchReader::FixPatchRoot(Bugzilla->params->{'cvsroot'});
+ $last_reader->sends_data_to($fix_patch_root);
+ $last_reader = $fix_patch_root;
+ }
+
+ # Grabs the patch file info.
+ my $patch_info_grabber = new PatchReader::PatchInfoGrabber();
+ $last_reader->sends_data_to($patch_info_grabber);
+ $last_reader = $patch_info_grabber;
+
+ # Prints out to temporary file.
+ my ($fh, $filename) = File::Temp::tempfile();
+ if ($format ne 'raw' && Bugzilla->params->{'utf8'}) {
+ # The HTML page will be displayed with the UTF-8 encoding.
+ binmode $fh, ':utf8';
+ }
+ my $raw_printer = new PatchReader::DiffPrinter::raw($fh);
+ $last_reader->sends_data_to($raw_printer);
+ $last_reader = $raw_printer;
+
+ # Iterate!
+ $reader->iterate_string($attachment->id, $attachment->data);
+
+ return ($filename, $patch_info_grabber->patch_info()->{files});
+}
+
+sub warn_if_interdiff_might_fail {
+ my ($old_file_list, $new_file_list) = @_;
+
+ # Verify that the list of files diffed is the same.
+ my @old_files = sort keys %{$old_file_list};
+ my @new_files = sort keys %{$new_file_list};
+ if (@old_files != @new_files
+ || join(' ', @old_files) ne join(' ', @new_files))
+ {
+ return 'interdiff1';
+ }
+
+ # Verify that the revisions in the files are the same.
+ foreach my $file (keys %{$old_file_list}) {
+ if (exists $old_file_list->{$file}{old_revision}
+ && exists $new_file_list->{$file}{old_revision}
+ && $old_file_list->{$file}{old_revision} ne
+ $new_file_list->{$file}{old_revision})
+ {
+ return 'interdiff2';
+ }
+ }
+ return undef;
+}
+
+sub setup_patch_readers {
+ my ($diff_root, $context) = @_;
+
+ # Parameters:
+ # format=raw|html
+ # context=patch|file|0-n
+ # collapsed=0|1
+ # headers=0|1
+
+ # Define the patch readers.
+ # The reader that reads the patch in (whatever its format).
+ require PatchReader::Raw;
+ my $reader = new PatchReader::Raw;
+ my $last_reader = $reader;
+ # Fix the patch root if we have a cvs root.
+ if (Bugzilla->params->{'cvsroot'}) {
+ require PatchReader::FixPatchRoot;
+ $last_reader->sends_data_to(new PatchReader::FixPatchRoot(Bugzilla->params->{'cvsroot'}));
+ $last_reader->sends_data_to->diff_root($diff_root) if defined($diff_root);
+ $last_reader = $last_reader->sends_data_to;
+ }
+
+ # Add in cvs context if we have the necessary info to do it
+ if ($context ne 'patch' && Bugzilla->localconfig->{cvsbin}
+ && Bugzilla->params->{'cvsroot_get'})
+ {
+ require PatchReader::AddCVSContext;
+ # We need to set $cvsbin as global, because PatchReader::CVSClient
+ # needs it in order to find 'cvs'.
+ $main::cvsbin = Bugzilla->localconfig->{cvsbin};
+ $last_reader->sends_data_to(
+ new PatchReader::AddCVSContext($context, Bugzilla->params->{'cvsroot_get'}));
+ $last_reader = $last_reader->sends_data_to;
+ }
+
+ return ($reader, $last_reader);
+}
+
+sub setup_template_patch_reader {
+ my ($last_reader, $format, $context, $vars) = @_;
+ my $cgi = Bugzilla->cgi;
+ my $template = Bugzilla->template;
+
+ require PatchReader::DiffPrinter::template;
+
+ # Define the vars for templates.
+ if (defined $cgi->param('headers')) {
+ $vars->{'headers'} = $cgi->param('headers');
+ }
+ else {
+ $vars->{'headers'} = 1;
+ }
+
+ $vars->{'collapsed'} = $cgi->param('collapsed');
+ $vars->{'context'} = $context;
+ $vars->{'do_context'} = Bugzilla->localconfig->{cvsbin}
+ && Bugzilla->params->{'cvsroot_get'} && !$vars->{'newid'};
+
+ # Print everything out.
+ print $cgi->header(-type => 'text/html');
+
+ $last_reader->sends_data_to(new PatchReader::DiffPrinter::template($template,
+ "attachment/diff-header.$format.tmpl",
+ "attachment/diff-file.$format.tmpl",
+ "attachment/diff-footer.$format.tmpl",
+ { %{$vars},
+ bonsai_url => Bugzilla->params->{'bonsai_url'},
+ lxr_url => Bugzilla->params->{'lxr_url'},
+ lxr_root => Bugzilla->params->{'lxr_root'},
+ }));
+}
+
+1;
+
+__END__
diff --git a/Bugzilla/Auth.pm b/Bugzilla/Auth.pm
new file mode 100644
index 0000000..09a2c1d
--- /dev/null
+++ b/Bugzilla/Auth.pm
@@ -0,0 +1,543 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth;
+
+use strict;
+use fields qw(
+ _info_getter
+ _verifier
+ _persister
+);
+
+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;
+use Socket;
+
+sub new {
+ my ($class, $params) = @_;
+ my $self = fields::new($class);
+
+ $params ||= {};
+ $params->{Login} ||= Bugzilla->params->{'user_info_class'} . ',Cookie';
+ $params->{Verify} ||= Bugzilla->params->{'user_verify_class'};
+
+ $self->{_info_getter} = new Bugzilla::Auth::Login::Stack($params->{Login});
+ $self->{_verifier} = new Bugzilla::Auth::Verify::Stack($params->{Verify});
+ # If we ever have any other login persistence methods besides cookies,
+ # this could become more configurable.
+ $self->{_persister} = new Bugzilla::Auth::Persist::Cookie();
+
+ return $self;
+}
+
+sub login {
+ my ($self, $type) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ # Get login info from the cookie, form, environment variables, etc.
+ my $login_info = $self->{_info_getter}->get_login_info();
+
+ if ($login_info->{failure}) {
+ return $self->_handle_login_result($login_info, $type);
+ }
+
+ # Now verify his username and password against the DB, LDAP, etc.
+ if ($self->{_info_getter}->{successful}->requires_verification) {
+ $login_info = $self->{_verifier}->check_credentials($login_info);
+ if ($login_info->{failure}) {
+ return $self->_handle_login_result($login_info, $type);
+ }
+ $login_info =
+ $self->{_verifier}->{successful}->create_or_update_user($login_info);
+ }
+ else {
+ $login_info = $self->{_verifier}->create_or_update_user($login_info);
+ }
+
+ if ($login_info->{failure}) {
+ return $self->_handle_login_result($login_info, $type);
+ }
+
+ # Make sure the user isn't disabled.
+ my $user = $login_info->{user};
+ if (!$user->is_enabled) {
+ return $self->_handle_login_result({ failure => AUTH_DISABLED,
+ user => $user }, $type);
+ }
+ $user->set_authorizer($self);
+
+ return $self->_handle_login_result($login_info, $type);
+}
+
+sub can_change_password {
+ my ($self) = @_;
+ my $verifier = $self->{_verifier}->{successful};
+ $verifier ||= $self->{_verifier};
+ my $getter = $self->{_info_getter}->{successful};
+ $getter = $self->{_info_getter}
+ if (!$getter || $getter->isa('Bugzilla::Auth::Login::Cookie'));
+ return $verifier->can_change_password &&
+ $getter->user_can_create_account;
+}
+
+sub can_login {
+ my ($self) = @_;
+ my $getter = $self->{_info_getter}->{successful};
+ $getter = $self->{_info_getter}
+ if (!$getter || $getter->isa('Bugzilla::Auth::Login::Cookie'));
+ return $getter->can_login;
+}
+
+sub can_logout {
+ my ($self) = @_;
+ my $getter = $self->{_info_getter}->{successful};
+ # If there's no successful getter, we're not logged in, so of
+ # course we can't log out!
+ return 0 unless $getter;
+ return $getter->can_logout;
+}
+
+sub login_token {
+ my ($self) = @_;
+ my $getter = $self->{_info_getter}->{successful};
+ if ($getter && $getter->isa('Bugzilla::Auth::Login::Cookie')) {
+ return $getter->login_token;
+ }
+ return undef;
+}
+
+sub user_can_create_account {
+ my ($self) = @_;
+ my $verifier = $self->{_verifier}->{successful};
+ $verifier ||= $self->{_verifier};
+ my $getter = $self->{_info_getter}->{successful};
+ $getter = $self->{_info_getter}
+ if (!$getter || $getter->isa('Bugzilla::Auth::Login::Cookie'));
+ return $verifier->user_can_create_account
+ && $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;
+}
+
+sub _handle_login_result {
+ my ($self, $result, $login_type) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $user = $result->{user};
+ my $fail_code = $result->{failure};
+
+ if (!$fail_code) {
+ # 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})
+ {
+ $user->{_login_token} = $self->{_persister}->persist_login($user);
+ }
+ }
+ elsif ($fail_code == AUTH_ERROR) {
+ 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)
+ if $login_type == LOGIN_REQUIRED;
+
+ # If we're not LOGIN_REQUIRED, we just return the default user.
+ $user = Bugzilla->user;
+ }
+ # The username/password may be wrong
+ # Don't let the user know whether the username exists or whether
+ # 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) {
+ 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) {
+ $self->{_persister}->logout();
+ # XXX This is NOT a good way to do this, architecturally.
+ $self->{_persister}->clear_browser_cookies();
+ # and throw a user error
+ 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 $address = $attempts->[0]->{ip_addr};
+ # Note: inet_aton will only resolve IPv4 addresses.
+ # For IPv6 we'll need to use inet_pton which requires Perl 5.12.
+ my $n = inet_aton($address);
+ if ($n) {
+ $address = gethostbyaddr($n, AF_INET) . " ($address)"
+ }
+ my $vars = {
+ locked_user => $user,
+ attempts => $attempts,
+ unlock_at => $unlock_at,
+ address => $address,
+ };
+ 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 });
+ }
+
+ return $user;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::Auth - An object that authenticates the login credentials for
+ a user.
+
+=head1 DESCRIPTION
+
+Handles authentication for Bugzilla users.
+
+Authentication from Bugzilla involves two sets of modules. One set is
+used to obtain the username/password (from CGI, email, etc), and the
+other set uses this data to authenticate against the datasource
+(the Bugzilla DB, LDAP, PAM, etc.).
+
+Modules for obtaining the username/password are subclasses of
+L<Bugzilla::Auth::Login>, and modules for authenticating are subclasses
+of L<Bugzilla::Auth::Verify>.
+
+=head1 AUTHENTICATION ERROR CODES
+
+Whenever a method in the C<Bugzilla::Auth> family fails in some way,
+it will return a hashref containing at least a single key called C<failure>.
+C<failure> will point to an integer error code, and depending on the error
+code the hashref may contain more data.
+
+The error codes are explained here below.
+
+=head2 C<AUTH_NODATA>
+
+Insufficient login data was provided by the user. This may happen in several
+cases, such as cookie authentication when the cookie is not present.
+
+=head2 C<AUTH_ERROR>
+
+An error occurred when trying to use the login mechanism.
+
+The hashref will also contain an C<error> element, which is the name
+of an error from C<template/en/default/global/code-error.html> --
+the same type of error that would be thrown by
+L<Bugzilla::Error::ThrowCodeError>.
+
+The hashref *may* contain an element called C<details>, which is a hashref
+that should be passed to L<Bugzilla::Error::ThrowCodeError> as the
+various fields to be used in the error message.
+
+=head2 C<AUTH_LOGINFAILED>
+
+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>.
+Modules should throw this error when they discover that the
+requested user account actually does not exist, according to them.
+
+That is, for example, L<Bugzilla::Auth::Verify::LDAP> would throw
+this if the user didn't exist in LDAP.
+
+The difference between C<AUTH_NO_SUCH_USER> and C<AUTH_LOGINFAILED>
+should never be communicated to the user, for security reasons.
+
+=head2 C<AUTH_DISABLED>
+
+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
+on what constant you pass into it:
+
+=head2 C<LOGIN_OPTIONAL>
+
+A login is never required to access this data. Attempting to login is
+still useful, because this allows the page to be personalised. Note that
+an incorrect login will still trigger an error, even though the lack of
+a login will be OK.
+
+=head2 C<LOGIN_NORMAL>
+
+A login may or may not be required, depending on the setting of the
+I<requirelogin> parameter. This is the default if you don't specify a
+type.
+
+=head2 C<LOGIN_REQUIRED>
+
+A login is always required to access this data.
+
+=head1 METHODS
+
+These are methods that can be called on a C<Bugzilla::Auth> object
+itself.
+
+=head2 Login
+
+=over 4
+
+=item C<login($type)>
+
+Description: Logs a user in. For more details on how this works
+ internally, see the section entitled "STRUCTURE."
+Params: $type - One of the Login Types from above.
+Returns: An authenticated C<Bugzilla::User>. Or, if the type was
+ not C<LOGIN_REQUIRED>, then we return an
+ empty C<Bugzilla::User> if no login data was passed in.
+
+=back
+
+=head2 Info Methods
+
+These are methods that give information about the Bugzilla::Auth object.
+
+=over 4
+
+=item C<can_change_password>
+
+Description: Tells you whether or not the current login system allows
+ changing passwords.
+Params: None
+Returns: C<true> if users and administrators should be allowed to
+ change passwords, C<false> otherwise.
+
+=item C<can_login>
+
+Description: Tells you whether or not the current login system allows
+ users to log in through the web interface.
+Params: None
+Returns: C<true> if users can log in through the web interface,
+ C<false> otherwise.
+
+=item C<can_logout>
+
+Description: Tells you whether or not the current login system allows
+ users to log themselves out.
+Params: None
+Returns: C<true> if users can log themselves out, C<false> otherwise.
+ If a user isn't logged in, we always return C<false>.
+
+=item C<user_can_create_account>
+
+Description: Tells you whether or not users are allowed to manually create
+ their own accounts, based on the current login system in use.
+ Note that this doesn't check the C<createemailregexp>
+ parameter--you have to do that by yourself in your code.
+Params: None
+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
+ change their own email address.
+Params: None
+Returns: C<true> if users can change their own email address,
+ C<false> otherwise.
+
+=item C<login_token>
+
+Description: If a login token was used instead of a cookie then this
+ will return the current login token data such as user id
+ and the token itself.
+Params: None
+Returns: A hash containing C<login_token> and C<user_id>.
+
+=back
+
+=head1 STRUCTURE
+
+This section is mostly interesting to developers who want to implement
+a new authentication type. It describes the general structure of the
+Bugzilla::Auth family, and how the C<login> function works.
+
+A C<Bugzilla::Auth> object is essentially a collection of a few other
+objects: the "Info Getter," the "Verifier," and the "Persistence
+Mechanism."
+
+They are used inside the C<login> function in the following order:
+
+=head2 The Info Getter
+
+This is a C<Bugzilla::Auth::Login> object. Basically, it gets the
+username and password from the user, somehow. Or, it just gets enough
+information to uniquely identify a user, and passes that on down the line.
+(For example, a C<user_id> is enough to uniquely identify a user,
+even without a username and password.)
+
+Some Info Getters don't require any verification. For example, if we got
+the C<user_id> from a Cookie, we don't need to check the username and
+password.
+
+If an Info Getter returns only a C<user_id> and no username/password,
+then it MUST NOT require verification. If an Info Getter requires
+verfication, then it MUST return at least a C<username>.
+
+=head2 The Verifier
+
+This verifies that the username and password are valid.
+
+It's possible that some methods of verification don't require a password.
+
+=head2 The Persistence Mechanism
+
+This makes it so that the user doesn't have to log in on every page.
+Normally this object just sends a cookie to the user's web browser,
+as that's the most common method of "login persistence."
+
+=head2 Other Things We Do
+
+After we verify the username and password, sometimes we automatically
+create an account in the Bugzilla database, for certain authentication
+types. We use the "Account Source" to get data about the user, and
+create them in the database. (Or, if their data has changed since the
+last time they logged in, their data gets updated.)
+
+=head2 The C<$login_data> Hash
+
+All of the C<Bugzilla::Auth::Login> and C<Bugzilla::Auth::Verify>
+methods take an argument called C<$login_data>. This is basically
+a hash that becomes more and more populated as we go through the
+C<login> function.
+
+All C<Bugzilla::Auth::Login> and C<Bugzilla::Auth::Verify> methods
+also *return* the C<$login_data> structure, when they succeed. They
+may have added new data to it.
+
+For all C<Bugzilla::Auth::Login> and C<Bugzilla::Auth::Verify> methods,
+the rule is "you must return the same hashref you were passed in." You can
+modify the hashref all you want, but you can't create a new one. The only
+time you can return a new one is if you're returning some error code
+instead of the C<$login_data> structure.
+
+Each C<Bugzilla::Auth::Login> or C<Bugzilla::Auth::Verify> method
+explains in its documentation which C<$login_data> elements are
+required by it, and which are set by it.
+
+Here are all of the elements that *may* be in C<$login_data>:
+
+=over 4
+
+=item C<user_id>
+
+A Bugzilla C<user_id> that uniquely identifies a user.
+
+=item C<username>
+
+The username that was provided by the user.
+
+=item C<bz_username>
+
+The username of this user inside of Bugzilla. Sometimes this differs from
+C<username>.
+
+=item C<password>
+
+The password provided by the user.
+
+=item C<realname>
+
+The real name of the user.
+
+=item C<extern_id>
+
+Some string that uniquely identifies the user in an external account
+source. If this C<extern_id> already exists in the database with
+a different username, the username will be *changed* to be the
+username specified in this C<$login_data>.
+
+That is, let's my extern_id is C<mkanat>. I already have an account
+in Bugzilla with the username of C<mkanat@foo.com>. But this time,
+when I log in, I have an extern_id of C<mkanat> and a C<username>
+of C<mkanat@bar.org>. So now, Bugzilla will automatically change my
+username to C<mkanat@bar.org> instead of C<mkanat@foo.com>.
+
+=item C<user>
+
+A L<Bugzilla::User> object representing the authenticated user.
+Note that C<Bugzilla::Auth::login> may modify this object at various points.
+
+=back
diff --git a/Bugzilla/Auth/Login.pm b/Bugzilla/Auth/Login.pm
new file mode 100644
index 0000000..290cb42
--- /dev/null
+++ b/Bugzilla/Auth/Login.pm
@@ -0,0 +1,134 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Login;
+
+use strict;
+use fields qw();
+
+# Determines whether or not a user can logout. It's really a subroutine,
+# but we implement it here as a constant. Override it in subclasses if
+# that particular type of login method cannot log out.
+use constant can_logout => 1;
+use constant can_login => 1;
+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) = @_;
+ my $self = fields::new($class);
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::Auth::Login - Gets username/password data from the user.
+
+=head1 DESCRIPTION
+
+Bugzilla::Auth::Login is used to get information that uniquely identifies
+a user and allows us to authorize their Bugzilla access.
+
+It is mostly an abstract class, requiring subclasses to implement
+most methods.
+
+Note that callers outside of the C<Bugzilla::Auth> package should never
+create this object directly. Just create a C<Bugzilla::Auth> object
+and call C<login> on it.
+
+=head1 LOGIN METHODS
+
+These are methods that have to do with getting the actual login data
+from the user or handling a login somehow.
+
+These methods are abstract -- they MUST be implemented by a subclass.
+
+=over 4
+
+=item C<get_login_info()>
+
+Description: Gets a username/password from the user, or some other
+ information that uniquely identifies them.
+Params: None
+Returns: A C<$login_data> hashref. (See L<Bugzilla::Auth> for details.)
+ The hashref MUST contain: C<user_id> *or* C<username>
+ If this is a login method that requires verification,
+ the hashref MUST contain C<password>.
+ The hashref MAY contain C<realname> and C<extern_id>.
+
+=item C<fail_nodata()>
+
+Description: This function is called when Bugzilla doesn't get
+ a username/password and the login type is C<LOGIN_REQUIRED>
+ (See L<Bugzilla::Auth> for a description of C<LOGIN_REQUIRED>).
+ That is, this handles C<AUTH_NODATA> in that situation.
+
+ This function MUST stop CGI execution when it is complete.
+ That is, it must call C<exit> or C<ThrowUserError> or some
+ such thing.
+Params: None
+Returns: Never Returns.
+
+=back
+
+=head1 INFO METHODS
+
+These are methods that describe the capabilities of this
+C<Bugzilla::Auth::Login> object. These are all no-parameter
+methods that return either C<true> or C<false>.
+
+=over 4
+
+=item C<can_logout>
+
+Whether or not users can log out if they logged in using this
+object. Defaults to C<true>.
+
+=item C<can_login>
+
+Whether or not users can log in through the web interface using
+this object. Defaults to C<true>.
+
+=item C<requires_persistence>
+
+Whether or not we should send the user a cookie if they logged in with
+this method. Defaults to C<true>.
+
+=item C<requires_verification>
+
+Whether or not we should check the username/password that we
+got from this login method. Defaults to C<true>.
+
+=item C<user_can_create_account>
+
+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
diff --git a/Bugzilla/Auth/Login/CGI.pm b/Bugzilla/Auth/Login/CGI.pm
new file mode 100644
index 0000000..090680e
--- /dev/null
+++ b/Bugzilla/Auth/Login/CGI.pm
@@ -0,0 +1,81 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Login::CGI;
+use strict;
+use base qw(Bugzilla::Auth::Login);
+use constant user_can_create_account => 1;
+
+use Bugzilla::Constants;
+use Bugzilla::WebService::Constants;
+use Bugzilla::Util;
+use Bugzilla::Error;
+use Bugzilla::Token;
+
+sub get_login_info {
+ my ($self) = @_;
+ my $params = Bugzilla->input_params;
+ my $cgi = Bugzilla->cgi;
+
+ my $login = trim(delete $params->{'Bugzilla_login'});
+ my $password = delete $params->{'Bugzilla_password'};
+ # The token must match the cookie to authenticate the request.
+ my $login_token = delete $params->{'Bugzilla_login_token'};
+ my $login_cookie = $cgi->cookie('Bugzilla_login_request_cookie');
+
+ my $valid = 0;
+ # If the web browser accepts cookies, use them.
+ if ($login_token && $login_cookie) {
+ my ($time, undef) = split(/-/, $login_token);
+ # Regenerate the token based on the information we have.
+ my $expected_token = issue_hash_token(['login_request', $login_cookie], $time);
+ $valid = 1 if $expected_token eq $login_token;
+ $cgi->remove_cookie('Bugzilla_login_request_cookie');
+ }
+ # WebServices and other local scripts can bypass this check.
+ # This is safe because we won't store a login cookie in this case.
+ elsif (Bugzilla->usage_mode != USAGE_MODE_BROWSER) {
+ $valid = 1;
+ }
+ # Else falls back to the Referer header and accept local URLs.
+ # Attachments are served from a separate host (ideally), and so
+ # an evil attachment cannot abuse this check with a redirect.
+ elsif (my $referer = $cgi->referer) {
+ my $urlbase = correct_urlbase();
+ $valid = 1 if $referer =~ /^\Q$urlbase\E/;
+ }
+ # If the web browser doesn't accept cookies and the Referer header
+ # is missing, we have no way to make sure that the authentication
+ # request comes from the user.
+ elsif ($login && $password) {
+ ThrowUserError('auth_untrusted_request', { login => $login });
+ }
+
+ if (!$login || !$password || !$valid) {
+ return { failure => AUTH_NODATA };
+ }
+
+ return { username => $login, password => $password };
+}
+
+sub fail_nodata {
+ my ($self) = @_;
+ my $cgi = Bugzilla->cgi;
+ my $template = Bugzilla->template;
+
+ if (Bugzilla->usage_mode != USAGE_MODE_BROWSER) {
+ ThrowUserError('login_required');
+ }
+
+ print $cgi->header();
+ $template->process("account/auth/login.html.tmpl",
+ { 'target' => $cgi->url(-relative=>1) })
+ || ThrowTemplateError($template->error());
+ exit;
+}
+
+1;
diff --git a/Bugzilla/Auth/Login/Cookie.pm b/Bugzilla/Auth/Login/Cookie.pm
new file mode 100644
index 0000000..b203573
--- /dev/null
+++ b/Bugzilla/Auth/Login/Cookie.pm
@@ -0,0 +1,121 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Login::Cookie;
+
+use strict;
+
+use base qw(Bugzilla::Auth::Login);
+use fields qw(_login_token);
+
+use Bugzilla::Constants;
+use Bugzilla::Util;
+
+use List::Util qw(first);
+
+use constant requires_persistence => 0;
+use constant requires_verification => 0;
+use constant can_login => 0;
+
+sub is_automatic { return $_[0]->login_token ? 0 : 1; }
+
+# Note that Cookie never consults the Verifier, it always assumes
+# it has a valid DB account or it fails.
+sub get_login_info {
+ my ($self) = @_;
+ my $cgi = Bugzilla->cgi;
+ my $dbh = Bugzilla->dbh;
+ my ($user_id, $login_cookie);
+
+ if (!Bugzilla->request_cache->{auth_no_automatic_login}) {
+ $login_cookie = $cgi->cookie("Bugzilla_logincookie");
+ $user_id = $cgi->cookie("Bugzilla_login");
+
+ # If cookies cannot be found, this could mean that they haven't
+ # been made available yet. In this case, look at Bugzilla_cookie_list.
+ unless ($login_cookie) {
+ my $cookie = first {$_->name eq 'Bugzilla_logincookie'}
+ @{$cgi->{'Bugzilla_cookie_list'}};
+ $login_cookie = $cookie->value if $cookie;
+ }
+ unless ($user_id) {
+ my $cookie = first {$_->name eq 'Bugzilla_login'}
+ @{$cgi->{'Bugzilla_cookie_list'}};
+ $user_id = $cookie->value if $cookie;
+ }
+ }
+
+ # If no cookies were provided, we also look for a login token
+ # passed in the parameters of a webservice
+ my $token = $self->login_token;
+ if ($token && (!$login_cookie || !$user_id)) {
+ ($user_id, $login_cookie) = ($token->{'user_id'}, $token->{'login_token'});
+ }
+
+ my $ip_addr = remote_ip();
+
+ if ($login_cookie && $user_id) {
+ # Anything goes for these params - they're just strings which
+ # we're going to verify against the db
+ trick_taint($ip_addr);
+ trick_taint($login_cookie);
+ detaint_natural($user_id);
+
+ my $db_cookie =
+ $dbh->selectrow_array('SELECT cookie
+ 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 (defined $db_cookie && $login_cookie eq $db_cookie) {
+ # If we logged in successfully, then update the lastused
+ # time on the login cookie
+ $dbh->do("UPDATE logincookies SET lastused = NOW()
+ WHERE cookie = ?", undef, $login_cookie);
+ return { user_id => $user_id };
+ }
+ }
+
+ # Either the he cookie is invalid, or we got no cookie. We don't want
+ # to ever return AUTH_LOGINFAILED, because we don't want Bugzilla to
+ # actually throw an error when it gets a bad cookie. It should just
+ # look like there was no cookie to begin with.
+ return { failure => AUTH_NODATA };
+}
+
+sub login_token {
+ my ($self) = @_;
+ my $input = Bugzilla->input_params;
+ my $usage_mode = Bugzilla->usage_mode;
+
+ return $self->{'_login_token'} if exists $self->{'_login_token'};
+
+ if ($usage_mode ne USAGE_MODE_XMLRPC
+ && $usage_mode ne USAGE_MODE_JSON)
+ {
+ return $self->{'_login_token'} = undef;
+ }
+
+ # Check if a token was passed in via requests for WebServices
+ my $token = trim(delete $input->{'Bugzilla_token'});
+ return $self->{'_login_token'} = undef if !$token;
+
+ my ($user_id, $login_token) = split('-', $token, 2);
+ if (!detaint_natural($user_id) || !$login_token) {
+ return $self->{'_login_token'} = undef;
+ }
+
+ return $self->{'_login_token'} = {
+ user_id => $user_id,
+ login_token => $login_token
+ };
+}
+
+1;
diff --git a/Bugzilla/Auth/Login/Env.pm b/Bugzilla/Auth/Login/Env.pm
new file mode 100644
index 0000000..393ac60
--- /dev/null
+++ b/Bugzilla/Auth/Login/Env.pm
@@ -0,0 +1,40 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Login::Env;
+use strict;
+use base qw(Bugzilla::Auth::Login);
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+
+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) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $env_id = $ENV{Bugzilla->params->{"auth_env_id"}} || '';
+ my $env_email = $ENV{Bugzilla->params->{"auth_env_email"}} || '';
+ my $env_realname = $ENV{Bugzilla->params->{"auth_env_realname"}} || '';
+
+ return { failure => AUTH_NODATA } if !$env_email;
+
+ return { username => $env_email, extern_id => $env_id,
+ realname => $env_realname };
+}
+
+sub fail_nodata {
+ ThrowCodeError('env_no_email');
+}
+
+1;
diff --git a/Bugzilla/Auth/Login/Stack.pm b/Bugzilla/Auth/Login/Stack.pm
new file mode 100644
index 0000000..17a5855
--- /dev/null
+++ b/Bugzilla/Auth/Login/Stack.pm
@@ -0,0 +1,98 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Login::Stack;
+use strict;
+use base qw(Bugzilla::Auth::Login);
+use fields qw(
+ _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)) {
+ my $module = $methods{$login_method};
+ require $module;
+ $module =~ s|/|::|g;
+ $module =~ s/.pm$//;
+ push(@{$self->{_stack}}, $module->new(@_));
+ }
+ return $self;
+}
+
+sub get_login_info {
+ 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;
+
+ # 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;
+}
+
+sub fail_nodata {
+ my $self = shift;
+ # We fail from the bottom of the stack.
+ my @reverse_stack = reverse @{$self->{_stack}};
+ foreach my $object (@reverse_stack) {
+ # We pick the first object that actually has the method
+ # implemented.
+ if ($object->can('fail_nodata')) {
+ $object->fail_nodata(@_);
+ }
+ }
+}
+
+sub can_login {
+ my ($self) = @_;
+ # We return true if any method can log in.
+ foreach my $object (@{$self->{_stack}}) {
+ return 1 if $object->can_login;
+ }
+ return 0;
+}
+
+sub user_can_create_account {
+ my ($self) = @_;
+ # We return true if any method allows users to create accounts.
+ foreach my $object (@{$self->{_stack}}) {
+ return 1 if $object->user_can_create_account;
+ }
+ return 0;
+}
+
+sub extern_id_used {
+ my ($self) = @_;
+ return any { $_->extern_id_used } @{ $self->{_stack} };
+}
+
+1;
diff --git a/Bugzilla/Auth/Persist/Cookie.pm b/Bugzilla/Auth/Persist/Cookie.pm
new file mode 100644
index 0000000..b0aeb4f
--- /dev/null
+++ b/Bugzilla/Auth/Persist/Cookie.pm
@@ -0,0 +1,159 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Persist::Cookie;
+use strict;
+use fields qw();
+
+use Bugzilla::Constants;
+use Bugzilla::Util;
+use Bugzilla::Token;
+
+use List::Util qw(first);
+
+sub new {
+ my ($class) = @_;
+ my $self = fields::new($class);
+ return $self;
+}
+
+sub persist_login {
+ my ($self, $user) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $cgi = Bugzilla->cgi;
+ 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);
+ }
+
+ $dbh->bz_start_transaction();
+
+ my $login_cookie =
+ Bugzilla::Token::GenerateUniqueToken('logincookies', 'cookie');
+
+ $dbh->do("INSERT INTO logincookies (cookie, userid, ipaddr, lastused)
+ VALUES (?, ?, ?, NOW())",
+ undef, $login_cookie, $user->id, $ip_addr);
+
+ # Issuing a new cookie is a good time to clean up the old
+ # cookies.
+ $dbh->do("DELETE FROM logincookies WHERE lastused < "
+ . $dbh->sql_date_math('LOCALTIMESTAMP(0)', '-',
+ MAX_LOGINCOOKIE_AGE, 'DAY'));
+
+ $dbh->bz_commit_transaction();
+
+ # We do not want WebServices to generate login cookies.
+ # All we need is the login token for User.login.
+ return $login_cookie if i_am_webservice();
+
+ # Prevent JavaScript from accessing login cookies.
+ my %cookieargs = ('-httponly' => 1);
+
+ # Remember cookie only if admin has told so
+ # or admin didn't forbid it and user told to remember.
+ if ( Bugzilla->params->{'rememberlogin'} eq 'on' ||
+ (Bugzilla->params->{'rememberlogin'} ne 'off' &&
+ $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_redirect'}) {
+ # Make these cookies only be sent to us by the browser during
+ # HTTPS sessions, if we're using SSL.
+ $cookieargs{'-secure'} = 1;
+ }
+
+ $cgi->send_cookie(-name => 'Bugzilla_login',
+ -value => $user->id,
+ %cookieargs);
+ $cgi->send_cookie(-name => 'Bugzilla_logincookie',
+ -value => $login_cookie,
+ %cookieargs);
+}
+
+sub logout {
+ my ($self, $param) = @_;
+
+ my $dbh = Bugzilla->dbh;
+ my $cgi = Bugzilla->cgi;
+ my $input = Bugzilla->input_params;
+ $param = {} unless $param;
+ my $user = $param->{user} || Bugzilla->user;
+ my $type = $param->{type} || LOGOUT_ALL;
+
+ if ($type == LOGOUT_ALL) {
+ $dbh->do("DELETE FROM logincookies WHERE userid = ?",
+ undef, $user->id);
+ return;
+ }
+
+ # The LOGOUT_*_CURRENT options require the current login cookie.
+ # If a new cookie has been issued during this run, that's the current one.
+ # If not, it's the one we've received.
+ my @login_cookies;
+ my $cookie = first {$_->name eq 'Bugzilla_logincookie'}
+ @{$cgi->{'Bugzilla_cookie_list'}};
+ if ($cookie) {
+ push(@login_cookies, $cookie->value);
+ }
+ elsif ($cookie = $cgi->cookie("Bugzilla_logincookie")) {
+ push(@login_cookies, $cookie);
+ }
+
+ # If we are a webservice using a token instead of cookie
+ # then add that as well to the login cookies to delete
+ if (my $login_token = $user->authorizer->login_token) {
+ push(@login_cookies, $login_token->{'login_token'});
+ }
+
+ # Make sure that @login_cookies is not empty to not break SQL statements.
+ push(@login_cookies, '') unless @login_cookies;
+
+ # These queries use both the cookie ID and the user ID as keys. Even
+ # though we know the userid must match, we still check it in the SQL
+ # as a sanity check, since there is no locking here, and if the user
+ # logged out from two machines simultaneously, while someone else
+ # logged in and got the same cookie, we could be logging the other
+ # user out here. Yes, this is very very very unlikely, but why take
+ # chances? - bbaetz
+ map { trick_taint($_) } @login_cookies;
+ @login_cookies = map { $dbh->quote($_) } @login_cookies;
+ if ($type == LOGOUT_KEEP_CURRENT) {
+ $dbh->do("DELETE FROM logincookies WHERE " .
+ $dbh->sql_in('cookie', \@login_cookies, 1) .
+ " AND userid = ?",
+ undef, $user->id);
+ } elsif ($type == LOGOUT_CURRENT) {
+ $dbh->do("DELETE FROM logincookies WHERE " .
+ $dbh->sql_in('cookie', \@login_cookies) .
+ " AND userid = ?",
+ undef, $user->id);
+ } else {
+ die("Invalid type $type supplied to logout()");
+ }
+
+ if ($type != LOGOUT_KEEP_CURRENT) {
+ clear_browser_cookies();
+ }
+}
+
+sub clear_browser_cookies {
+ my $cgi = Bugzilla->cgi;
+ $cgi->remove_cookie('Bugzilla_login');
+ $cgi->remove_cookie('Bugzilla_logincookie');
+ $cgi->remove_cookie('sudo');
+}
+
+1;
diff --git a/Bugzilla/Auth/Verify.pm b/Bugzilla/Auth/Verify.pm
new file mode 100644
index 0000000..ae256dd
--- /dev/null
+++ b/Bugzilla/Auth/Verify.pm
@@ -0,0 +1,235 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Verify;
+
+use strict;
+use fields qw();
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::User;
+use Bugzilla::Util;
+
+use constant user_can_create_account => 1;
+use constant extern_id_used => 0;
+
+sub new {
+ my ($class, $login_type) = @_;
+ my $self = fields::new($class);
+ return $self;
+}
+
+sub can_change_password {
+ return $_[0]->can('change_password');
+}
+
+sub create_or_update_user {
+ my ($self, $params) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $extern_id = $params->{extern_id};
+ my $username = $params->{bz_username} || $params->{username};
+ my $password = $params->{password} || '*';
+ my $real_name = $params->{realname} || '';
+ my $user_id = $params->{user_id};
+
+ # A passed-in user_id always overrides anything else, for determining
+ # what account we should return.
+ if (!$user_id) {
+ my $username_user_id = login_to_id($username || '');
+ my $extern_user_id;
+ if ($extern_id) {
+ trick_taint($extern_id);
+ $extern_user_id = $dbh->selectrow_array('SELECT userid
+ FROM profiles WHERE extern_id = ?', undef, $extern_id);
+ }
+
+ # If we have both a valid extern_id and a valid username, and they are
+ # not the same id, then we have a conflict.
+ if ($username_user_id && $extern_user_id
+ && $username_user_id ne $extern_user_id)
+ {
+ my $extern_name = Bugzilla::User->new($extern_user_id)->login;
+ return { failure => AUTH_ERROR, error => "extern_id_conflict",
+ details => {extern_id => $extern_id,
+ extern_user => $extern_name,
+ username => $username} };
+ }
+
+ # If we have a valid username, but no valid id,
+ # then we have to create the user. This happens when we're
+ # passed only a username, and that username doesn't exist already.
+ if ($username && !$username_user_id && !$extern_user_id) {
+ validate_email_syntax($username)
+ || return { failure => AUTH_ERROR,
+ error => 'auth_invalid_email',
+ details => {addr => $username} };
+ # Usually we'd call validate_password, but external authentication
+ # systems might follow different standards than ours. So in this
+ # place here, we call trick_taint without checks.
+ trick_taint($password);
+
+ # XXX Theoretically this could fail with an error, but the fix for
+ # that is too involved to be done right now.
+ my $user = Bugzilla::User->create({
+ login_name => $username,
+ cryptpassword => $password,
+ realname => $real_name});
+ $username_user_id = $user->id;
+ }
+
+ # If we have a valid username id and an extern_id, but no valid
+ # extern_user_id, then we have to set the user's extern_id.
+ if ($extern_id && $username_user_id && !$extern_user_id) {
+ $dbh->do('UPDATE profiles SET extern_id = ? WHERE userid = ?',
+ undef, $extern_id, $username_user_id);
+ }
+
+ # Finally, at this point, one of these will give us a valid user id.
+ $user_id = $extern_user_id || $username_user_id;
+ }
+
+ # If we still don't have a valid user_id, then we weren't passed
+ # enough information in $params, and we should die right here.
+ ThrowCodeError('bad_arg', {argument => 'params', function =>
+ 'Bugzilla::Auth::Verify::create_or_update_user'})
+ unless $user_id;
+
+ my $user = new Bugzilla::User($user_id);
+
+ # Now that we have a valid User, we need to see if any data has to be
+ # updated.
+ if ($username && lc($user->login) ne lc($username)) {
+ validate_email_syntax($username)
+ || return { failure => AUTH_ERROR, error => 'auth_invalid_email',
+ details => {addr => $username} };
+ $user->set_login($username);
+ }
+ if ($real_name && $user->name ne $real_name) {
+ # $real_name is more than likely tainted, but we only use it
+ # in a placeholder and we never use it after this.
+ trick_taint($real_name);
+ $user->set_name($real_name);
+ }
+ $user->update();
+
+ return { user => $user };
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::Auth::Verify - An object that verifies usernames and passwords.
+
+=head1 DESCRIPTION
+
+Bugzilla::Auth::Verify provides the "Verifier" part of the Bugzilla
+login process. (For details, see the "STRUCTURE" section of
+L<Bugzilla::Auth>.)
+
+It is mostly an abstract class, requiring subclasses to implement
+most methods.
+
+Note that callers outside of the C<Bugzilla::Auth> package should never
+create this object directly. Just create a C<Bugzilla::Auth> object
+and call C<login> on it.
+
+=head1 VERIFICATION METHODS
+
+These are the methods that have to do with the actual verification.
+
+Subclasses MUST implement these methods.
+
+=over 4
+
+=item C<check_credentials($login_data)>
+
+Description: Checks whether or not a username is valid.
+Params: $login_data - A C<$login_data> hashref, as described in
+ L<Bugzilla::Auth>.
+ This C<$login_data> hashref MUST contain
+ C<username>, and SHOULD also contain
+ C<password>.
+Returns: A C<$login_data> hashref with C<bz_username> set. This
+ method may also set C<realname>. It must avoid changing
+ anything that is already set.
+
+=back
+
+=head1 MODIFICATION METHODS
+
+These are methods that change data in the actual authentication backend.
+
+These methods are optional, they do not have to be implemented by
+subclasses.
+
+=over 4
+
+=item C<create_or_update_user($login_data)>
+
+Description: Automatically creates a user account in the database
+ if it doesn't already exist, or updates the account
+ data if C<$login_data> contains newer information.
+
+Params: $login_data - A C<$login_data> hashref, as described in
+ L<Bugzilla::Auth>.
+ This C<$login_data> hashref MUST contain
+ either C<user_id>, C<bz_username>, or
+ C<username>. If both C<username> and C<bz_username>
+ are specified, C<bz_username> is used as the
+ login name of the user to create in the database.
+ It MAY also contain C<extern_id>, in which
+ case it still MUST contain C<bz_username> or
+ C<username>.
+ It MAY contain C<password> and C<realname>.
+
+Returns: A hashref with one element, C<user>, which is a
+ L<Bugzilla::User> object. May also return a login error
+ as described in L<Bugzilla::Auth>.
+
+Note: This method is not abstract, it is actually implemented
+ and creates accounts in the Bugzilla database. Subclasses
+ should probably all call the C<Bugzilla::Auth::Verify>
+ version of this function at the end of their own
+ C<create_or_update_user>.
+
+=item C<change_password($user, $password)>
+
+Description: Modifies the user's password in the authentication backend.
+Params: $user - A L<Bugzilla::User> object representing the user
+ whose password we want to change.
+ $password - The user's new password.
+Returns: Nothing.
+
+=back
+
+=head1 INFO METHODS
+
+These are methods that describe the capabilities of this object.
+These are all no-parameter methods that return either C<true> or
+C<false>.
+
+=over 4
+
+=item C<user_can_create_account>
+
+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
diff --git a/Bugzilla/Auth/Verify/DB.pm b/Bugzilla/Auth/Verify/DB.pm
new file mode 100644
index 0000000..6ca04f2
--- /dev/null
+++ b/Bugzilla/Auth/Verify/DB.pm
@@ -0,0 +1,86 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Verify::DB;
+use strict;
+use base qw(Bugzilla::Auth::Verify);
+
+use Bugzilla::Constants;
+use Bugzilla::Token;
+use Bugzilla::Util;
+use Bugzilla::User;
+
+sub check_credentials {
+ my ($self, $login_data) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $username = $login_data->{username};
+ my $user = new Bugzilla::User({ name => $username });
+
+ return { failure => AUTH_NO_SUCH_USER } unless $user;
+
+ $login_data->{user} = $user;
+ $login_data->{bz_username} = $user->login;
+
+ 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 = 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 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;
+}
+
+sub change_password {
+ my ($self, $user, $password) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $cryptpassword = bz_crypt($password);
+ $dbh->do("UPDATE profiles SET cryptpassword = ? WHERE userid = ?",
+ undef, $cryptpassword, $user->id);
+}
+
+1;
diff --git a/Bugzilla/Auth/Verify/LDAP.pm b/Bugzilla/Auth/Verify/LDAP.pm
new file mode 100644
index 0000000..5704c58
--- /dev/null
+++ b/Bugzilla/Auth/Verify/LDAP.pm
@@ -0,0 +1,178 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Verify::LDAP;
+use strict;
+use base qw(Bugzilla::Auth::Verify);
+use fields qw(
+ ldap
+);
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::User;
+use Bugzilla::Util;
+
+use Net::LDAP;
+use Net::LDAP::Util qw(escape_filter_value);
+
+use constant admin_can_create_account => 0;
+use constant user_can_create_account => 0;
+
+sub check_credentials {
+ my ($self, $params) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ # We need to bind anonymously to the LDAP server. This is
+ # because we need to get the Distinguished Name of the user trying
+ # to log in. Some servers (such as iPlanet) allow you to have unique
+ # uids spread out over a subtree of an area (such as "People"), so
+ # 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_for_search();
+
+ # Now, we verify that the user exists, and get a LDAP Distinguished
+ # Name for the user.
+ my $username = $params->{username};
+ my $dn_result = $self->ldap->search(_bz_search_params($username),
+ attrs => ['dn']);
+ return { failure => AUTH_ERROR, error => "ldap_search_error",
+ details => {errstr => $dn_result->error, username => $username}
+ } if $dn_result->code;
+
+ return { failure => AUTH_NO_SUCH_USER } if !$dn_result->count;
+
+ my $dn = $dn_result->shift_entry->dn;
+
+ # Check the password.
+ my $pw_result = $self->ldap->bind($dn, password => $params->{password});
+ 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 => $error_string, username => $username}
+ } if !$user_entry;
+
+
+ my $mail_attr = Bugzilla->params->{"LDAPmailattribute"};
+ if ($mail_attr) {
+ if (!$user_entry->exists($mail_attr)) {
+ return { failure => AUTH_ERROR,
+ error => "ldap_cannot_retreive_attr",
+ details => {attr => $mail_attr} };
+ }
+
+ my @emails = $user_entry->get_value($mail_attr);
+
+ # Default to the first email address returned.
+ $params->{bz_username} = $emails[0];
+
+ if (@emails > 1) {
+ # Cycle through the adresses and check if they're Bugzilla logins.
+ # Use the first one that returns a valid id.
+ foreach my $email (@emails) {
+ if ( login_to_id($email) ) {
+ $params->{bz_username} = $email;
+ last;
+ }
+ }
+ }
+
+ } else {
+ $params->{bz_username} = $username;
+ }
+
+ $params->{realname} ||= $user_entry->get_value("displayName");
+ $params->{realname} ||= $user_entry->get_value("cn");
+
+ $params->{extern_id} = $username;
+
+ return $params;
+}
+
+sub _bz_search_params {
+ my ($username) = @_;
+ $username = escape_filter_value($username);
+ return (base => Bugzilla->params->{"LDAPBaseDN"},
+ scope => "sub",
+ filter => '(&(' . Bugzilla->params->{"LDAPuidattribute"}
+ . "=$username)"
+ . Bugzilla->params->{"LDAPfilter"} . ')');
+}
+
+sub _bind_ldap_for_search {
+ my ($self) = @_;
+ my $bind_result;
+ if (Bugzilla->params->{"LDAPbinddn"}) {
+ my ($LDAPbinddn,$LDAPbindpass) =
+ split(":",Bugzilla->params->{"LDAPbinddn"});
+ $bind_result =
+ $self->ldap->bind($LDAPbinddn, password => $LDAPbindpass);
+ }
+ else {
+ $bind_result = $self->ldap->bind();
+ }
+ ThrowCodeError("ldap_bind_failed", {errstr => $bind_result->error})
+ if $bind_result->code;
+}
+
+# We can't just do this in new(), because we're not allowed to throw any
+# error from anywhere under Bugzilla::Auth::new -- otherwise we
+# could create a situation where the admin couldn't get to editparams
+# to fix his mistake. (Because Bugzilla->login always calls
+# Bugzilla::Auth->new, and almost every page calls Bugzilla->login.)
+sub ldap {
+ my ($self) = @_;
+ return $self->{ldap} if $self->{ldap};
+
+ my @servers = split(/[\s,]+/, Bugzilla->params->{"LDAPserver"});
+ ThrowCodeError("ldap_server_not_defined") unless @servers;
+
+ foreach (@servers) {
+ $self->{ldap} = new Net::LDAP(trim($_));
+ last if $self->{ldap};
+ }
+ ThrowCodeError("ldap_connect_failed", { server => join(", ", @servers) })
+ unless $self->{ldap};
+
+ # try to start TLS if needed
+ if (Bugzilla->params->{"LDAPstarttls"}) {
+ my $mesg = $self->{ldap}->start_tls();
+ ThrowCodeError("ldap_start_tls_failed", { error => $mesg->error() })
+ if $mesg->code();
+ }
+
+ return $self->{ldap};
+}
+
+1;
diff --git a/Bugzilla/Auth/Verify/RADIUS.pm b/Bugzilla/Auth/Verify/RADIUS.pm
new file mode 100644
index 0000000..d6c4db8
--- /dev/null
+++ b/Bugzilla/Auth/Verify/RADIUS.pm
@@ -0,0 +1,51 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Verify::RADIUS;
+use strict;
+use base qw(Bugzilla::Auth::Verify);
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Util;
+
+use Authen::Radius;
+
+use constant admin_can_create_account => 0;
+use constant user_can_create_account => 0;
+
+sub check_credentials {
+ my ($self, $params) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $address_suffix = Bugzilla->params->{'RADIUS_email_suffix'};
+ my $username = $params->{username};
+
+ # If we're using RADIUS_email_suffix, we may need to cut it off from
+ # the login name.
+ if ($address_suffix) {
+ $username =~ s/\Q$address_suffix\E$//i;
+ }
+
+ # Create RADIUS object.
+ my $radius =
+ new Authen::Radius(Host => Bugzilla->params->{'RADIUS_server'},
+ Secret => Bugzilla->params->{'RADIUS_secret'})
+ || return { failure => AUTH_ERROR, error => 'radius_preparation_error',
+ details => {errstr => Authen::Radius::strerror() } };
+
+ # Check the password.
+ $radius->check_pwd($username, $params->{password},
+ Bugzilla->params->{'RADIUS_NAS_IP'} || undef)
+ || return { failure => AUTH_LOGINFAILED };
+
+ # Build the user account's e-mail address.
+ $params->{bz_username} = $username . $address_suffix;
+
+ return $params;
+}
+
+1;
diff --git a/Bugzilla/Auth/Verify/Stack.pm b/Bugzilla/Auth/Verify/Stack.pm
new file mode 100644
index 0000000..0930d57
--- /dev/null
+++ b/Bugzilla/Auth/Verify/Stack.pm
@@ -0,0 +1,88 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Auth::Verify::Stack;
+use strict;
+use base qw(Bugzilla::Auth::Verify);
+use fields qw(
+ _stack
+ 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)) {
+ my $module = $methods{$verify_method};
+ require $module;
+ $module =~ s|/|::|g;
+ $module =~ s/.pm$//;
+ push(@{$self->{_stack}}, $module->new(@_));
+ }
+ return $self;
+}
+
+sub can_change_password {
+ my ($self) = @_;
+ # We return true if any method can change passwords.
+ foreach my $object (@{$self->{_stack}}) {
+ return 1 if $object->can_change_password;
+ }
+ return 0;
+}
+
+sub check_credentials {
+ my $self = shift;
+ my $result;
+ foreach my $object (@{$self->{_stack}}) {
+ $result = $object->check_credentials(@_);
+ $self->{successful} = $object;
+ last if !$result->{failure};
+ # So that if none of them succeed, it's undef.
+ $self->{successful} = undef;
+ }
+ # Returns the result at the bottom of the stack if they all fail.
+ return $result;
+}
+
+sub create_or_update_user {
+ my $self = shift;
+ my $result;
+ foreach my $object (@{$self->{_stack}}) {
+ $result = $object->create_or_update_user(@_);
+ last if !$result->{failure};
+ }
+ # Returns the result at the bottom of the stack if they all fail.
+ return $result;
+}
+
+sub user_can_create_account {
+ my ($self) = @_;
+ # We return true if any method allows the user to create an account.
+ foreach my $object (@{$self->{_stack}}) {
+ return 1 if $object->user_can_create_account;
+ }
+ return 0;
+}
+
+sub extern_id_used {
+ my ($self) = @_;
+ return any { $_->extern_id_used } @{ $self->{_stack} };
+}
+
+1;
diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm
new file mode 100644
index 0000000..97e81df
--- /dev/null
+++ b/Bugzilla/Bug.pm
@@ -0,0 +1,4297 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Bug;
+
+use strict;
+
+use Bugzilla::Attachment;
+use Bugzilla::Constants;
+use Bugzilla::Field;
+use Bugzilla::Flag;
+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::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
+ LogActivityEntry
+ editable_bug_fields
+);
+
+#####################################################################
+# Constants
+#####################################################################
+
+use constant DB_TABLE => 'bugs';
+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 $dbh = Bugzilla->dbh;
+ my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT}
+ Bugzilla->active_custom_fields;
+ my @custom_names = map {$_->name} @custom;
+
+ my @columns = (qw(
+ alias
+ assigned_to
+ bug_file_loc
+ bug_id
+ bug_severity
+ bug_status
+ cclist_accessible
+ component_id
+ delta_ts
+ estimated_time
+ everconfirmed
+ lastdiffed
+ op_sys
+ priority
+ product_id
+ qa_contact
+ remaining_time
+ rep_platform
+ reporter_accessible
+ resolution
+ short_desc
+ status_whiteboard
+ target_milestone
+ version
+ ),
+ '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);
+
+ Bugzilla::Hook::process("bug_columns", { columns => \@columns });
+
+ return @columns;
+}
+
+sub VALIDATORS {
+
+ my $validators = {
+ alias => \&_check_alias,
+ assigned_to => \&_check_assigned_to,
+ blocked => \&_check_dependencies,
+ bug_file_loc => \&_check_bug_file_loc,
+ bug_severity => \&_check_select_field,
+ bug_status => \&_check_bug_status,
+ cc => \&_check_cc,
+ comment => \&_check_comment,
+ component => \&_check_component,
+ creation_ts => \&_check_creation_ts,
+ deadline => \&_check_deadline,
+ dependson => \&_check_dependencies,
+ 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,
+ 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.
+ foreach my $field (Bugzilla->active_custom_fields) {
+ my $validator;
+ if ($field->type == FIELD_TYPE_SINGLE_SELECT) {
+ $validator = \&_check_select_field;
+ }
+ elsif ($field->type == FIELD_TYPE_MULTI_SELECT) {
+ $validator = \&_check_multi_select_field;
+ }
+ elsif ($field->type == FIELD_TYPE_DATETIME) {
+ $validator = \&_check_datetime_field;
+ }
+ elsif ($field->type == FIELD_TYPE_FREETEXT) {
+ $validator = \&_check_freetext_field;
+ }
+ elsif ($field->type == FIELD_TYPE_BUG_ID) {
+ $validator = \&_check_bugid_field;
+ }
+ elsif ($field->type == FIELD_TYPE_TEXTAREA) {
+ $validator = \&_check_textarea_field;
+ }
+ else {
+ $validator = \&_check_default_field;
+ }
+ $validators->{$field->name} = $validator;
+ }
+
+ return $validators;
+};
+
+sub VALIDATOR_DEPENDENCIES {
+ my $cache = Bugzilla->request_cache;
+ return $cache->{bug_validator_dependencies}
+ if $cache->{bug_validator_dependencies};
+
+ my %deps = (
+ assigned_to => ['component'],
+ blocked => ['product'],
+ bug_status => ['product', 'comment', 'target_milestone'],
+ cc => ['component'],
+ comment => ['creation_ts'],
+ component => ['product'],
+ dependson => ['product'],
+ dup_id => ['bug_status', 'resolution'],
+ groups => ['product'],
+ keywords => ['product'],
+ resolution => ['bug_status', 'dependson'],
+ 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 {
+ my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT}
+ Bugzilla->active_custom_fields;
+ my @custom_names = map {$_->name} @custom;
+ my @columns = qw(
+ alias
+ assigned_to
+ bug_file_loc
+ bug_severity
+ bug_status
+ cclist_accessible
+ component_id
+ deadline
+ estimated_time
+ everconfirmed
+ op_sys
+ priority
+ product_id
+ qa_contact
+ remaining_time
+ rep_platform
+ reporter_accessible
+ resolution
+ short_desc
+ status_whiteboard
+ target_milestone
+ version
+ );
+ push(@columns, @custom_names);
+ return @columns;
+};
+
+use constant NUMERIC_COLUMNS => qw(
+ estimated_time
+ remaining_time
+);
+
+sub DATE_COLUMNS {
+ my @fields = @{ Bugzilla->fields({ type => FIELD_TYPE_DATETIME }) };
+ return map { $_->name } @fields;
+}
+
+# Used in LogActivityEntry(). Gives the max length of lines in the
+# activity table.
+use constant MAX_LINE_LENGTH => 254;
+
+# 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 ($param) {
+ $param = { name => $param };
+ }
+ else {
+ # We got something that's not a number.
+ my $error_self = {};
+ bless $error_self, $class;
+ $error_self->{'bug_id'} = $param;
+ $error_self->{'error'} = 'InvalidBugId';
+ return $error_self;
+ }
+ }
+
+ unshift @_, $param;
+ my $self = $class->SUPER::new(@_);
+
+ # Bugzilla::Bug->new always returns something, but sets $self->{error}
+ # 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;
+ 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.
+ $id = trim($id);
+ my $self = $class->new($id);
+
+ if ($self->{error}) {
+ # For error messages, use the id that was returned by new(), because
+ # it's cleaned up.
+ $id = $self->id;
+
+ 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($id);
+ }
+ return $self;
+}
+
+sub check_for_edit {
+ my $class = shift;
+ my $bug = $class->check(@_);
+
+ Bugzilla->user->can_edit_product($bug->product_id)
+ || ThrowUserError("product_edit_denied", { product => $bug->product });
+
+ return $bug;
+}
+
+sub check_is_visible {
+ my ($self, $input_id) = @_;
+ $input_id ||= $self->id;
+ 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 we are validating an alias, then use it in the error message
+ # instead of its corresponding bug ID, to not disclose it.
+ if ($user->id) {
+ ThrowUserError("bug_access_denied", { bug_id => $input_id });
+ } else {
+ ThrowUserError("bug_access_query", { bug_id => $input_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 });
+ push(@all_dep_ids, @{ $bug->duplicate_ids });
+ }
+ @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 and duplicate lists, in Bugzilla::Template::get_bug_link.
+ $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 || '');
+ # Remove leading/trailing punctuation from words
+ foreach my $word (@words) {
+ $word =~ s/(?:^\W+|\W+$)//g;
+ }
+ # 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);
+ $relevance_sql ||= $where_sql;
+ }
+ else {
+ my (@where, @relevance);
+ foreach my $word (@words) {
+ my ($term, $rel_term) = $dbh->sql_fulltext_search(
+ 'bugs_fulltext.short_desc', $word);
+ 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):
+#
+# The same as Bugzilla::Object->create. Parameters are only required
+# if they say so below.
+#
+# Params:
+#
+# C<product> - B<Required> The name of the product this bug is being
+# filed against.
+# C<component> - B<Required> The name of the component this bug is being
+# filed against.
+#
+# C<bug_severity> - B<Required> The severity for the bug, a string.
+# C<creation_ts> - B<Required> A SQL timestamp for when the bug was created.
+# C<short_desc> - B<Required> A summary for the bug.
+# C<op_sys> - B<Required> The OS the bug was found against.
+# C<priority> - B<Required> The initial priority for the bug.
+# C<rep_platform> - B<Required> The platform the bug was found against.
+# C<version> - B<Required> The version of the product the bug was found in.
+#
+# C<alias> - An alias for this bug.
+# C<target_milestone> - When this bug is expected to be fixed.
+# C<status_whiteboard> - A string.
+# C<bug_status> - The initial status of the bug, a string.
+# C<bug_file_loc> - The URL field.
+#
+# C<assigned_to> - The full login name of the user who the bug is
+# initially assigned to.
+# C<qa_contact> - The full login name of the QA Contact for this bug.
+# Will be ignored if C<useqacontact> is off.
+#
+# C<estimated_time> - For time-tracking. Will be ignored if
+# C<timetrackinggroup> is not set, or if the current
+# user is not a member of the timetrackinggroup.
+# C<deadline> - For time-tracking. Will be ignored for the same
+# reasons as C<estimated_time>.
+sub create {
+ my ($class, $params) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ $dbh->bz_start_transaction();
+
+ # These fields have default values which we can use if they are undefined.
+ $params->{bug_severity} = Bugzilla->params->{defaultseverity}
+ unless defined $params->{bug_severity};
+ $params->{priority} = Bugzilla->params->{defaultpriority}
+ unless defined $params->{priority};
+ $params->{op_sys} = Bugzilla->params->{defaultopsys}
+ unless defined $params->{op_sys};
+ $params->{rep_platform} = Bugzilla->params->{defaultplatform}
+ unless defined $params->{rep_platform};
+ # Make sure a comment is always defined.
+ $params->{comment} = '' unless defined $params->{comment};
+
+ $class->check_required_create_fields($params);
+ $params = $class->run_create_validators($params);
+
+ # 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 $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.
+ my $timestamp = delete $params->{creation_ts};
+
+ my $ms_values = $class->_extract_multi_selects($params);
+ my $bug = $class->insert_create_data($params);
+
+ # Add the group restrictions
+ my $sth_group = $dbh->prepare(
+ 'INSERT INTO bug_group_map (bug_id, group_id) VALUES (?, ?)');
+ foreach my $group (@$groups) {
+ $sth_group->execute($bug->bug_id, $group->id);
+ }
+
+ $dbh->do('UPDATE bugs SET creation_ts = ? WHERE bug_id = ?', undef,
+ $timestamp, $bug->bug_id);
+ # Update the bug instance as well
+ $bug->{creation_ts} = $timestamp;
+
+ # Add the CCs
+ my $sth_cc = $dbh->prepare('INSERT INTO cc (bug_id, who) VALUES (?,?)');
+ foreach my $user_id (@$cc_ids) {
+ $sth_cc->execute($bug->bug_id, $user_id);
+ }
+
+ # Add in keywords
+ my $sth_keyword = $dbh->prepare(
+ 'INSERT INTO keywords (bug_id, keywordid) VALUES (?, ?)');
+ foreach my $keyword_id (map($_->id, @$keywords)) {
+ $sth_keyword->execute($bug->bug_id, $keyword_id);
+ }
+
+ # Set up dependencies (blocked/dependson)
+ my $sth_deps = $dbh->prepare(
+ 'INSERT INTO dependencies (blocked, dependson) VALUES (?, ?)');
+ my $sth_bug_time = $dbh->prepare('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?');
+
+ foreach my $depends_on_id (@$depends_on) {
+ $sth_deps->execute($bug->bug_id, $depends_on_id);
+ # Log the reverse action on the other bug.
+ LogActivityEntry($depends_on_id, 'blocked', '', $bug->bug_id,
+ $bug->{reporter_id}, $timestamp);
+ $sth_bug_time->execute($timestamp, $depends_on_id);
+ }
+ foreach my $blocked_id (@$blocked) {
+ $sth_deps->execute($blocked_id, $bug->bug_id);
+ # Log the reverse action on the other bug.
+ LogActivityEntry($blocked_id, 'dependson', '', $bug->bug_id,
+ $bug->{reporter_id}, $timestamp);
+ $sth_bug_time->execute($timestamp, $blocked_id);
+ }
+
+ # Insert the values into the multiselect value tables
+ foreach my $field (keys %$ms_values) {
+ $dbh->do("DELETE FROM bug_$field where bug_id = ?",
+ undef, $bug->bug_id);
+ foreach my $value ( @{$ms_values->{$field}} ) {
+ $dbh->do("INSERT INTO bug_$field (bug_id, value) VALUES (?,?)",
+ undef, $bug->bug_id, $value);
+ }
+ }
+
+ # 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.
+ Bugzilla::Comment->insert_create_data($creation_comment);
+
+ Bugzilla::Hook::process('bug_end_of_create', { bug => $bug,
+ timestamp => $timestamp,
+ });
+
+ $dbh->bz_commit_transaction();
+
+ # Because MySQL doesn't support transactions on the fulltext table,
+ # we do this after we've committed the transaction. That way we're
+ # sure we're inserting a good Bug ID.
+ $bug->_sync_fulltext( new_bug => 1 );
+
+ return $bug;
+}
+
+sub run_create_validators {
+ my $class = shift;
+ my $params = $class->SUPER::run_create_validators(@_);
+
+ # Add classification for checking mandatory fields which depend on it
+ $params->{classification} = $params->{product}->classification->name;
+
+ 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);
+ }
+
+ my $product = delete $params->{product};
+ $params->{product_id} = $product->id;
+ my $component = delete $params->{component};
+ $params->{component_id} = $component->id;
+
+ # Callers cannot set reporter, creation_ts, or delta_ts.
+ $params->{reporter} = $class->_check_reporter();
+ $params->{delta_ts} = $params->{creation_ts};
+
+ if ($params->{estimated_time}) {
+ $params->{remaining_time} = $params->{estimated_time};
+ }
+
+ $class->_check_strict_isolation($params->{cc}, $params->{assigned_to},
+ $params->{qa_contact}, $product);
+
+ # You can't set these fields.
+ delete $params->{lastdiffed};
+ delete $params->{bug_id};
+ delete $params->{classification};
+
+ Bugzilla::Hook::process('bug_end_of_create_validators',
+ { params => $params });
+
+ # And this is not a valid DB field, it's just used as part of
+ # _check_dependencies to avoid running it twice for both blocked
+ # and dependson.
+ delete $params->{_dependencies_validated};
+
+ return $params;
+}
+
+sub update {
+ my $self = shift;
+
+ 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 LOCALTIMESTAMP(0)');
+
+ $dbh->bz_start_transaction();
+
+ my ($changes, $old_bug) = $self->SUPER::update(@_);
+
+ Bugzilla::Hook::process('bug_start_of_update',
+ { timestamp => $delta_ts, bug => $self,
+ old_bug => $old_bug, changes => $changes });
+
+ # Certain items in $changes have to be fixed so that they hold
+ # a name instead of an ID.
+ foreach my $field (qw(product_id component_id)) {
+ my $change = delete $changes->{$field};
+ if ($change) {
+ my $new_field = $field;
+ $new_field =~ s/_id$//;
+ $changes->{$new_field} =
+ [$self->{"_old_${new_field}_name"}, $self->$new_field];
+ }
+ }
+ foreach my $field (qw(qa_contact assigned_to)) {
+ if ($changes->{$field}) {
+ my ($from, $to) = @{ $changes->{$field} };
+ $from = $old_bug->$field->login if $from;
+ $to = $self->$field->login if $to;
+ $changes->{$field} = [$from, $to];
+ }
+ }
+
+ # CC
+ my @old_cc = map {$_->id} @{$old_bug->cc_users};
+ my @new_cc = map {$_->id} @{$self->cc_users};
+ my ($removed_cc, $added_cc) = diff_arrays(\@old_cc, \@new_cc);
+
+ if (scalar @$removed_cc) {
+ $dbh->do('DELETE FROM cc WHERE bug_id = ? AND '
+ . $dbh->sql_in('who', $removed_cc), undef, $self->id);
+ }
+ foreach my $user_id (@$added_cc) {
+ $dbh->do('INSERT INTO cc (bug_id, who) VALUES (?,?)',
+ undef, $self->id, $user_id);
+ }
+ # If any changes were found, record it in the activity log
+ if (scalar @$removed_cc || scalar @$added_cc) {
+ my $removed_users = Bugzilla::User->new_from_list($removed_cc);
+ my $added_users = Bugzilla::User->new_from_list($added_cc);
+ my $removed_names = join(', ', (map {$_->login} @$removed_users));
+ my $added_names = join(', ', (map {$_->login} @$added_users));
+ $changes->{cc} = [$removed_names, $added_names];
+ }
+
+ # Keywords
+ my @old_kw_ids = map { $_->id } @{$old_bug->keyword_objects};
+ my @new_kw_ids = map { $_->id } @{$self->keyword_objects};
+
+ my ($removed_kw, $added_kw) = diff_arrays(\@old_kw_ids, \@new_kw_ids);
+
+ if (scalar @$removed_kw) {
+ $dbh->do('DELETE FROM keywords WHERE bug_id = ? AND '
+ . $dbh->sql_in('keywordid', $removed_kw), undef, $self->id);
+ }
+ foreach my $keyword_id (@$added_kw) {
+ $dbh->do('INSERT INTO keywords (bug_id, keywordid) VALUES (?,?)',
+ undef, $self->id, $keyword_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);
+ my $added_keywords = Bugzilla::Keyword->new_from_list($added_kw);
+ my $removed_names = join(', ', (map {$_->name} @$removed_keywords));
+ my $added_names = join(', ', (map {$_->name} @$added_keywords));
+ $changes->{keywords} = [$removed_names, $added_names];
+ }
+
+ # Dependencies
+ foreach my $pair ([qw(dependson blocked)], [qw(blocked dependson)]) {
+ my ($type, $other) = @$pair;
+ my $old = $old_bug->$type;
+ my $new = $self->$type;
+
+ my ($removed, $added) = diff_arrays($old, $new);
+ foreach my $removed_id (@$removed) {
+ $dbh->do("DELETE FROM dependencies WHERE $type = ? AND $other = ?",
+ undef, $removed_id, $self->id);
+
+ # Add an activity entry for the other bug.
+ LogActivityEntry($removed_id, $other, $self->id, '',
+ Bugzilla->user->id, $delta_ts);
+ # Update delta_ts on the other bug so that we trigger mid-airs.
+ $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
+ undef, $delta_ts, $removed_id);
+ }
+ foreach my $added_id (@$added) {
+ $dbh->do("INSERT INTO dependencies ($type, $other) VALUES (?,?)",
+ undef, $added_id, $self->id);
+
+ # Add an activity entry for the other bug.
+ LogActivityEntry($added_id, $other, '', $self->id,
+ Bugzilla->user->id, $delta_ts);
+ # Update delta_ts on the other bug so that we trigger mid-airs.
+ $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
+ undef, $delta_ts, $added_id);
+ }
+
+ if (scalar(@$removed) || scalar(@$added)) {
+ $changes->{$type} = [join(', ', @$removed), join(', ', @$added)];
+ }
+ }
+
+ # Groups
+ my %old_groups = map {$_->id => $_} @{$old_bug->groups_in};
+ my %new_groups = map {$_->id => $_} @{$self->groups_in};
+ my ($removed_gr, $added_gr) = diff_arrays([keys %old_groups],
+ [keys %new_groups]);
+ if (scalar @$removed_gr || scalar @$added_gr) {
+ if (@$removed_gr) {
+ my $qmarks = join(',', ('?') x @$removed_gr);
+ $dbh->do("DELETE FROM bug_group_map
+ WHERE bug_id = ? AND group_id IN ($qmarks)", undef,
+ $self->id, @$removed_gr);
+ }
+ my $sth_insert = $dbh->prepare(
+ 'INSERT INTO bug_group_map (bug_id, group_id) VALUES (?,?)');
+ foreach my $gid (@$added_gr) {
+ $sth_insert->execute($self->id, $gid);
+ }
+ my @removed_names = map { $old_groups{$_}->name } @$removed_gr;
+ my @added_names = map { $new_groups{$_}->name } @$added_gr;
+ $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} || []}) {
+ # 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);
+ }
+ }
+
+ # 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
+ my @multi_selects = grep {$_->type == FIELD_TYPE_MULTI_SELECT}
+ Bugzilla->active_custom_fields;
+ foreach my $field (@multi_selects) {
+ my $name = $field->name;
+ my ($removed, $added) = diff_arrays($old_bug->$name, $self->$name);
+ if (scalar @$removed || scalar @$added) {
+ $changes->{$name} = [join(', ', @$removed), join(', ', @$added)];
+
+ $dbh->do("DELETE FROM bug_$name where bug_id = ?",
+ undef, $self->id);
+ foreach my $value (@{$self->$name}) {
+ $dbh->do("INSERT INTO bug_$name (bug_id, value) VALUES (?,?)",
+ undef, $self->id, $value);
+ }
+ }
+ }
+
+ # 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.
+ foreach my $field (keys %$changes) {
+ my $change = $changes->{$field};
+ my $from = defined $change->[0] ? $change->[0] : '';
+ my $to = defined $change->[1] ? $change->[1] : '';
+ LogActivityEntry($self->id, $field, $from, $to, Bugzilla->user->id,
+ $delta_ts);
+ }
+
+ # Check if we have to update the duplicates table and the other bug.
+ my ($old_dup, $cur_dup) = ($old_bug->dup_id || 0, $self->dup_id || 0);
+ if ($old_dup != $cur_dup) {
+ $dbh->do("DELETE FROM duplicates WHERE dupe = ?", undef, $self->id);
+ if ($cur_dup) {
+ $dbh->do('INSERT INTO duplicates (dupe, dupe_of) VALUES (?,?)',
+ undef, $self->id, $cur_dup);
+ if (my $update_dup = delete $self->{_dup_for_update}) {
+ $update_dup->update();
+ }
+ }
+
+ $changes->{'dup_id'} = [$old_dup || undef, $cur_dup || undef];
+ }
+
+ 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}
+ || $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(
+ update_short_desc => $changes->{short_desc},
+ update_comments => $self->{added_comments} || $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;
+}
+
+# Used by create().
+# We need to handle multi-select fields differently than normal fields,
+# because they're arrays and don't go into the bugs table.
+sub _extract_multi_selects {
+ my ($invocant, $params) = @_;
+
+ my @multi_selects = grep {$_->type == FIELD_TYPE_MULTI_SELECT}
+ Bugzilla->active_custom_fields;
+ my %ms_values;
+ foreach my $field (@multi_selects) {
+ my $name = $field->name;
+ if (exists $params->{$name}) {
+ my $array = delete($params->{$name}) || [];
+ $ms_values{$name} = $array;
+ }
+ }
+ return \%ms_values;
+}
+
+# Should be called any time you update short_desc or change a comment.
+sub _sync_fulltext {
+ my ($self, %options) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my($all_comments, $public_comments);
+ if ($options{new_bug} || $options{update_comments}) {
+ my $comments = $dbh->selectall_arrayref(
+ 'SELECT thetext, isprivate FROM longdescs WHERE bug_id = ?',
+ undef, $self->id);
+ $all_comments = join("\n", map { $_->[0] } @$comments);
+ my @no_private = grep { !$_->[1] } @$comments;
+ $public_comments = join("\n", map { $_->[0] } @no_private);
+ }
+
+ if ($options{new_bug}) {
+ $dbh->do('INSERT INTO bugs_fulltext (bug_id, short_desc, comments,
+ comments_noprivate)
+ VALUES (?, ?, ?, ?)',
+ undef,
+ $self->id, $self->short_desc, $all_comments, $public_comments);
+ } else {
+ my(@names, @values);
+ if ($options{update_short_desc}) {
+ push @names, 'short_desc';
+ push @values, $self->short_desc;
+ }
+ if ($options{update_comments}) {
+ push @names, ('comments', 'comments_noprivate');
+ push @values, ($all_comments, $public_comments);
+ }
+ if (@names) {
+ $dbh->do('UPDATE bugs_fulltext SET ' .
+ join(', ', map { "$_ = ?" } @names) .
+ ' WHERE bug_id = ?',
+ undef,
+ @values, $self->id);
+ }
+ }
+}
+
+sub remove_from_db {
+ my ($self) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ ThrowCodeError("bug_error", { bug => $self }) if $self->{'error'};
+
+ my $bug_id = $self->{'bug_id'};
+ $self->SUPER::remove_from_db();
+ # The bugs_fulltext table doesn't support foreign keys.
+ $dbh->do("DELETE FROM bugs_fulltext WHERE bug_id = ?", undef, $bug_id);
+}
+
+#####################################################################
+# 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'}
+ ? $changes->{'assigned_to'}->[0] : '';
+ my $old_cc = $changes->{cc}
+ ? $changes->{cc}->[0] : '';
+
+ my %forced = (
+ cc => [split(/[,;]+/, $old_cc)],
+ owner => $old_own,
+ qacontact => $old_qa,
+ changer => $user,
+ );
+
+ _send_bugmail({ id => $self->id, type => 'bug', forced => \%forced },
+ $vars);
+
+ # If the bug was marked as a duplicate, we need to notify users on the
+ # other bug of any changes to that bug.
+ my $new_dup_id = $changes->{'dup_id'} ? $changes->{'dup_id'}->[1] : undef;
+ if ($new_dup_id) {
+ _send_bugmail({ forced => { changer => $user }, type => "dupe",
+ id => $new_dup_id }, $vars);
+ }
+
+ # If there were changes in dependencies, we need to notify those
+ # dependencies.
+ if ($changes->{'bug_status'}) {
+ my ($old_status, $new_status) = @{ $changes->{'bug_status'} };
+
+ # If this bug has changed from opened to closed or vice-versa,
+ # then all of the bugs we block need to be notified.
+ if (is_open_state($old_status) ne is_open_state($new_status)) {
+ my $params = { forced => { changer => $user },
+ type => 'dep',
+ dep_only => 1,
+ blocker => $self,
+ changes => $changes };
+
+ foreach my $id (@{ $self->blocked }) {
+ $params->{id} = $id;
+ _send_bugmail($params, $vars);
+ }
+ }
+ }
+
+ # To get a list of all changed dependencies, convert the "changes" arrays
+ # into a long string, then collapse that string into unique numbers in
+ # a hash.
+ my $all_changed_deps = join(', ', @{ $changes->{'dependson'} || [] });
+ $all_changed_deps = join(', ', @{ $changes->{'blocked'} || [] },
+ $all_changed_deps);
+ my %changed_deps = map { $_ => 1 } split(', ', $all_changed_deps);
+ # When clearning one field (say, blocks) and filling in the other
+ # (say, dependson), an empty string can get into the hash and cause
+ # an error later.
+ delete $changed_deps{''};
+
+ foreach my $id (sort { $a <=> $b } (keys %changed_deps)) {
+ _send_bugmail({ forced => { changer => $user }, type => "dep",
+ id => $id }, $vars);
+ }
+
+ # Sending emails for the referenced bugs.
+ foreach my $ref_bug_id (uniq @{ $self->{see_also_changes} || [] }) {
+ _send_bugmail({ forced => { changer => $user },
+ id => $ref_bug_id }, $vars);
+ }
+}
+
+sub _send_bugmail {
+ my ($params, $vars) = @_;
+
+ require Bugzilla::BugMail;
+
+ my $results =
+ Bugzilla::BugMail::Send($params->{'id'}, $params->{'forced'}, $params);
+
+ if (Bugzilla->usage_mode == USAGE_MODE_BROWSER) {
+ my $template = Bugzilla->template;
+ $vars->{$_} = $params->{$_} foreach keys %$params;
+ $vars->{'sent_bugmail'} = $results;
+ $template->process("bug/process/results.html.tmpl", $vars)
+ || ThrowTemplateError($template->error());
+ $vars->{'header_done'} = 1;
+ }
+}
+
+#####################################################################
+# Validators
+#####################################################################
+
+sub _check_alias {
+ my ($invocant, $alias) = @_;
+ $alias = trim($alias);
+ return undef if (!$alias);
+
+ # Make sure the alias isn't too long.
+ if (length($alias) > 20) {
+ ThrowUserError("alias_too_long");
+ }
+ # Make sure the alias isn't just a number.
+ if ($alias =~ /^\d+$/) {
+ ThrowUserError("alias_is_numeric", { alias => $alias });
+ }
+ # Make sure the alias has no commas or spaces.
+ if ($alias =~ /[, ]/) {
+ ThrowUserError("alias_has_comma_or_space", { alias => $alias });
+ }
+ # Make sure the alias is unique, or that it's already our alias.
+ my $other_bug = new Bugzilla::Bug($alias);
+ if (!$other_bug->{error}
+ && (!ref $invocant || $other_bug->id != $invocant->id))
+ {
+ ThrowUserError("alias_in_use", { alias => $alias,
+ bug_id => $other_bug->id });
+ }
+
+ return $alias;
+}
+
+sub _check_assigned_to {
+ my ($invocant, $assignee, undef, $params) = @_;
+ my $user = Bugzilla->user;
+ my $component = blessed($invocant) ? $invocant->component_obj
+ : $params->{component};
+
+ # Default assignee is the component owner.
+ my $id;
+ # If this is a new bug, you can only set the assignee if you have editbugs.
+ # If you didn't specify the assignee, we use the default assignee.
+ if (!ref $invocant
+ && (!$user->in_group('editbugs', $component->product_id) || !$assignee))
+ {
+ $id = $component->default_assignee->id;
+ } else {
+ if (!ref $assignee) {
+ $assignee = trim($assignee);
+ # When updating a bug, assigned_to can't be empty.
+ ThrowUserError("reassign_to_empty") if ref $invocant && !$assignee;
+ $assignee = Bugzilla::User->check($assignee);
+ }
+ $id = $assignee->id;
+ # create() checks this another way, so we don't have to run this
+ # check during create().
+ $invocant->_check_strict_isolation_for_user($assignee) if ref $invocant;
+ }
+ return $id;
+}
+
+sub _check_bug_file_loc {
+ my ($invocant, $url) = @_;
+ $url = '' if !defined($url);
+ $url = trim($url);
+ # On bug entry, if bug_file_loc is "http://", the default, use an
+ # empty value instead. However, on bug editing people can set that
+ # back if they *really* want to.
+ if (!ref $invocant && $url eq 'http://') {
+ $url = '';
+ }
+ return $url;
+}
+
+sub _check_bug_status {
+ my ($invocant, $new_status, undef, $params) = @_;
+ my $user = Bugzilla->user;
+ my @valid_statuses;
+ my $old_status; # Note that this is undef for new bugs.
+
+ my ($product, $comment);
+ if (ref $invocant) {
+ @valid_statuses = @{$invocant->statuses_available};
+ $product = $invocant->product_obj;
+ $old_status = $invocant->status;
+ my $comments = $invocant->{added_comments} || [];
+ $comment = $comments->[-1];
+ }
+ else {
+ $product = $params->{product};
+ $comment = $params->{comment};
+ @valid_statuses = @{ Bugzilla::Bug->statuses_available($product) };
+ }
+
+ # Check permissions for users filing new bugs.
+ if (!ref $invocant) {
+ if ($user->in_group('editbugs', $product->id)
+ || $user->in_group('canconfirm', $product->id)) {
+ # If the user with privs hasn't selected another status,
+ # select the first one of the list.
+ unless ($new_status) {
+ if (scalar(@valid_statuses) == 1) {
+ $new_status = $valid_statuses[0];
+ }
+ else {
+ $new_status = ($valid_statuses[0]->name ne 'UNCONFIRMED') ?
+ $valid_statuses[0] : $valid_statuses[1];
+ }
+ }
+ }
+ else {
+ # A user with no privs cannot choose the initial status.
+ # If UNCONFIRMED is valid for this product, use it; else
+ # use the first bug status available.
+ if (grep {$_->name eq 'UNCONFIRMED'} @valid_statuses) {
+ $new_status = 'UNCONFIRMED';
+ }
+ else {
+ $new_status = $valid_statuses[0];
+ }
+ }
+ }
+
+ # Time to validate the bug status.
+ $new_status = Bugzilla::Status->check($new_status) unless ref($new_status);
+ # We skip this check if we are changing from a status to itself.
+ if ( (!$old_status || $old_status->id != $new_status->id)
+ && !grep {$_->name eq $new_status->name} @valid_statuses)
+ {
+ ThrowUserError('illegal_bug_status_transition',
+ { old => $old_status, new => $new_status });
+ }
+
+ # Check if a comment is required for this change.
+ if ($new_status->comment_required_on_change_from($old_status)
+ && !$comment->{'thetext'})
+ {
+ ThrowUserError('comment_required',
+ { old => $old_status ? $old_status->name : undef,
+ new => $new_status->name, field => 'bug_status' });
+ }
+
+ if (ref $invocant
+ && ($new_status->name eq 'IN_PROGRESS'
+ # Backwards-compat for the old default workflow.
+ or $new_status->name eq 'ASSIGNED')
+ && Bugzilla->params->{"usetargetmilestone"}
+ && Bugzilla->params->{"musthavemilestoneonaccept"}
+ # musthavemilestoneonaccept applies only if at least two
+ # target milestones are defined for the product.
+ && scalar(@{ $product->milestones }) > 1
+ && $invocant->target_milestone eq $product->default_milestone)
+ {
+ ThrowUserError("milestone_required", { bug => $invocant });
+ }
+
+ if (!blessed $invocant) {
+ $params->{everconfirmed} = $new_status->name eq 'UNCONFIRMED' ? 0 : 1;
+ }
+
+ return $new_status->name;
+}
+
+sub _check_cc {
+ my ($invocant, $ccs, undef, $params) = @_;
+ my $component = blessed($invocant) ? $invocant->component_obj
+ : $params->{component};
+ return [map {$_->id} @{$component->initial_cc}] unless $ccs;
+
+ # Allow comma-separated input as well as arrayrefs.
+ $ccs = [split(/[,;]+/, $ccs)] if !ref $ccs;
+
+ my %cc_ids;
+ foreach my $person (@$ccs) {
+ $person = trim($person);
+ next unless $person;
+ my $id = login_to_id($person, THROW_ERROR);
+ $cc_ids{$id} = 1;
+ }
+
+ # Enforce Default CC
+ $cc_ids{$_->id} = 1 foreach (@{$component->initial_cc});
+
+ return [keys %cc_ids];
+}
+
+sub _check_comment {
+ my ($invocant, $comment_txt, undef, $params) = @_;
+
+ # Comment can be empty. We should force it to be empty if the text is undef
+ if (!defined $comment_txt) {
+ $comment_txt = '';
+ }
+
+ # Load up some data
+ my $isprivate = delete $params->{comment_is_private};
+ my $timestamp = $params->{creation_ts};
+
+ # Create the new comment so we can check it
+ my $comment = {
+ thetext => $comment_txt,
+ bug_when => $timestamp,
+ };
+
+ # We don't include the "isprivate" column unless it was specified.
+ # This allows it to fall back to its database default.
+ if (defined $isprivate) {
+ $comment->{isprivate} = $isprivate;
+ }
+
+ # Validate comment. We have to do this special as a comment normally
+ # requires a bug to be already created. For a new bug, the first comment
+ # obviously can't get the bug if the bug is created after this
+ # (see bug 590334)
+ Bugzilla::Comment->check_required_create_fields($comment);
+ $comment = Bugzilla::Comment->run_create_validators($comment,
+ { skip => ['bug_id'] }
+ );
+
+ return $comment;
+}
+
+sub _check_commenton {
+ my ($invocant, $new_value, $field, $params) = @_;
+
+ my $has_comment =
+ ref($invocant) ? $invocant->{added_comments}
+ : (defined $params->{comment}
+ and $params->{comment}->{thetext} ne '');
+
+ my $is_changing = ref($invocant) ? $invocant->$field ne $new_value
+ : $new_value ne '';
+
+ if ($is_changing && !$has_comment) {
+ my $old_value = ref($invocant) ? $invocant->$field : undef;
+ ThrowUserError('comment_required',
+ { field => $field, old => $old_value, new => $new_value });
+ }
+}
+
+sub _check_component {
+ my ($invocant, $name, undef, $params) = @_;
+ $name = trim($name);
+ $name || ThrowUserError("require_component");
+ my $product = blessed($invocant) ? $invocant->product_obj
+ : $params->{product};
+ my $old_comp = blessed($invocant) ? $invocant->component : '';
+ my $object = Bugzilla::Component->check({ product => $product, name => $name });
+ if ($object->name ne $old_comp && !$object->is_active) {
+ ThrowUserError('value_inactive', { class => ref($object), value => $name });
+ }
+ return $object;
+}
+
+sub _check_creation_ts {
+ return Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
+}
+
+sub _check_deadline {
+ my ($invocant, $date) = @_;
+
+ # When filing bugs, we're forgiving and just return undef if
+ # the user isn't a timetracker. When updating bugs, check_can_change_field
+ # controls permissions, so we don't want to check them here.
+ if (!ref $invocant and !Bugzilla->user->is_timetracker) {
+ return undef;
+ }
+
+ # Validate entered deadline
+ $date = trim($date);
+ return undef if !$date;
+ validate_date($date)
+ || ThrowUserError('illegal_date', { date => $date,
+ format => 'YYYY-MM-DD' });
+ return $date;
+}
+
+# Takes two comma/space-separated strings and returns arrayrefs
+# of valid bug IDs.
+sub _check_dependencies {
+ my ($invocant, $value, $field, $params) = @_;
+
+ return $value if $params->{_dependencies_validated};
+
+ if (!ref $invocant) {
+ # Only editbugs users can set dependencies on bug entry.
+ return ([], []) unless Bugzilla->user->in_group(
+ 'editbugs', $params->{product}->id);
+ }
+
+ # This is done this way so that dependson and blocked can be in
+ # VALIDATORS, meaning that they can be in VALIDATOR_DEPENDENCIES,
+ # which means that they can be checked in the right order during
+ # bug creation.
+ my $opposite = $field eq 'dependson' ? 'blocked' : 'dependson';
+ my %deps_in = ($field => $value || '',
+ $opposite => $params->{$opposite} || '');
+
+ foreach my $type (qw(dependson blocked)) {
+ my @bug_ids = ref($deps_in{$type})
+ ? @{$deps_in{$type}}
+ : split(/[\s,]+/, $deps_in{$type});
+ # Eliminate nulls.
+ @bug_ids = grep {$_} @bug_ids;
+
+ my @check_access = @bug_ids;
+ # When we're updating a bug, only added or removed bug_ids are
+ # checked for whether or not we can see/edit those bugs.
+ if (ref $invocant) {
+ my $old = $invocant->$type;
+ my ($removed, $added) = diff_arrays($old, \@bug_ids);
+ @check_access = (@$added, @$removed);
+
+ # Check field permissions if we've changed anything.
+ if (@check_access) {
+ my $privs;
+ if (!$invocant->check_can_change_field($type, 0, 1, \$privs)) {
+ ThrowUserError('illegal_change', { field => $type,
+ privs => $privs });
+ }
+ }
+ }
+
+ my $user = Bugzilla->user;
+ foreach my $modified_id (@check_access) {
+ my $delta_bug = $invocant->check($modified_id);
+ # Under strict isolation, you can't modify a bug if you can't
+ # edit it, even if you can see it.
+ if (Bugzilla->params->{"strict_isolation"}) {
+ if (!$user->can_edit_product($delta_bug->{'product_id'})) {
+ ThrowUserError("illegal_change_deps", {field => $type});
+ }
+ }
+ }
+ # Replace all aliases by their corresponding bug ID.
+ @bug_ids = map { $_ =~ /^(\d+)$/ ? $1 : $invocant->check($_, $type)->id } @bug_ids;
+ $deps_in{$type} = \@bug_ids;
+ }
+
+ # And finally, check for dependency loops.
+ my $bug_id = ref($invocant) ? $invocant->id : 0;
+ my %deps = ValidateDependencies($deps_in{dependson}, $deps_in{blocked},
+ $bug_id);
+
+ $params->{$opposite} = $deps{$opposite};
+ $params->{_dependencies_validated} = 1;
+ return $deps{$field};
+}
+
+sub _check_dup_id {
+ my ($self, $dupe_of) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ # Store the bug ID/alias passed by the user for visibility checks.
+ my $orig_dupe_of = $dupe_of = trim($dupe_of);
+ $dupe_of || ThrowCodeError('undefined_field', { field => 'dup_id' });
+ # Validate the bug ID. The second argument will force check() to only
+ # make sure that the bug exists, and convert the alias to the bug ID
+ # if a string is passed. Group restrictions are checked below.
+ my $dupe_of_bug = $self->check($dupe_of, 'dup_id');
+ $dupe_of = $dupe_of_bug->id;
+
+ # If the dupe is unchanged, we have nothing more to check.
+ return $dupe_of if ($self->dup_id && $self->dup_id == $dupe_of);
+
+ # If we come here, then the duplicate is new. We have to make sure
+ # that we can view/change it (issue A on bug 96085).
+ $dupe_of_bug->check_is_visible($orig_dupe_of);
+
+ # Make sure a loop isn't created when marking this bug
+ # as duplicate.
+ _resolve_ultimate_dup_id($self->id, $dupe_of, 1);
+
+ my $cur_dup = $self->dup_id || 0;
+ if ($cur_dup != $dupe_of && Bugzilla->params->{'commentonduplicate'}
+ && !$self->{added_comments})
+ {
+ ThrowUserError('comment_required');
+ }
+
+ # Should we add the reporter to the CC list of the new bug?
+ # If he can see the bug...
+ if ($self->reporter->can_see_bug($dupe_of)) {
+ # We only add him if he's not the reporter of the other bug.
+ $self->{_add_dup_cc} = 1
+ if $dupe_of_bug->reporter->id != $self->reporter->id;
+ }
+ # What if the reporter currently can't see the new bug? In the browser
+ # interface, we prompt the user. In other interfaces, we default to
+ # not adding the user, as the safest option.
+ elsif (Bugzilla->usage_mode == USAGE_MODE_BROWSER) {
+ # If we've already confirmed whether the user should be added...
+ my $cgi = Bugzilla->cgi;
+ my $add_confirmed = $cgi->param('confirm_add_duplicate');
+ if (defined $add_confirmed) {
+ $self->{_add_dup_cc} = $add_confirmed;
+ }
+ else {
+ # Note that here we don't check if he user is already the reporter
+ # of the dupe_of bug, since we already checked if he can *see*
+ # the bug, above. People might have reporter_accessible turned
+ # off, but cclist_accessible turned on, so they might want to
+ # add the reporter even though he's already the reporter of the
+ # dup_of bug.
+ my $vars = {};
+ my $template = Bugzilla->template;
+ # Ask the user what they want to do about the reporter.
+ $vars->{'cclist_accessible'} = $dupe_of_bug->cclist_accessible;
+ $vars->{'original_bug_id'} = $dupe_of;
+ $vars->{'duplicate_bug_id'} = $self->id;
+ print $cgi->header();
+ $template->process("bug/process/confirm-duplicate.html.tmpl", $vars)
+ || ThrowTemplateError($template->error());
+ exit;
+ }
+ }
+
+ return $dupe_of;
+}
+
+sub _check_groups {
+ my ($invocant, $group_names, undef, $params) = @_;
+
+ my $bug_id = blessed($invocant) ? $invocant->id : undef;
+ my $product = blessed($invocant) ? $invocant->product_obj
+ : $params->{product};
+ my %add_groups;
+
+ # In email or WebServices, when the "groups" item actually
+ # isn't specified, then just add the default groups.
+ if (!defined $group_names) {
+ my $available = $product->groups_available;
+ foreach my $group (@$available) {
+ $add_groups{$group->id} = $group if $group->{is_default};
+ }
+ }
+ else {
+ # Allow a comma-separated list, for email_in.pl.
+ $group_names = [map { trim($_) } split(',', $group_names)]
+ if !ref $group_names;
+
+ # First check all the groups they chose to set.
+ my %args = ( product => $product->name, bug_id => $bug_id, action => 'add' );
+ foreach my $name (@$group_names) {
+ my $group = Bugzilla::Group->check_no_disclose({ %args, name => $name });
+
+ if (!$product->group_is_settable($group)) {
+ ThrowUserError('group_restriction_not_allowed', { %args, name => $name });
+ }
+ $add_groups{$group->id} = $group;
+ }
+ }
+
+ # Now enforce mandatory groups.
+ $add_groups{$_->id} = $_ foreach @{ $product->groups_mandatory };
+
+ my @add_groups = values %add_groups;
+ return \@add_groups;
+}
+
+sub _check_keywords {
+ my ($invocant, $keywords_in, undef, $params) = @_;
+
+ return [] if !defined $keywords_in;
+
+ my $keyword_array = $keywords_in;
+ if (!ref $keyword_array) {
+ $keywords_in = trim($keywords_in);
+ $keyword_array = [split(/[\s,]+/, $keywords_in)];
+ }
+
+ # On creation, only editbugs users can set keywords.
+ if (!ref $invocant) {
+ my $product = $params->{product};
+ return [] if !Bugzilla->user->in_group('editbugs', $product->id);
+ }
+
+ my %keywords;
+ foreach my $keyword (@$keyword_array) {
+ next unless $keyword;
+ my $obj = Bugzilla::Keyword->check($keyword);
+ $keywords{$obj->id} = $obj;
+ }
+ return [values %keywords];
+}
+
+sub _check_product {
+ my ($invocant, $name) = @_;
+ $name = trim($name);
+ # If we're updating the bug and they haven't changed the product,
+ # always allow it.
+ if (ref $invocant && lc($invocant->product_obj->name) eq lc($name)) {
+ return $invocant->product_obj;
+ }
+ # Check that the product exists and that the user
+ # is allowed to enter bugs into this product.
+ my $product = Bugzilla->user->can_enter_product($name, THROW_ERROR);
+ return $product;
+}
+
+sub _check_priority {
+ my ($invocant, $priority) = @_;
+ if (!ref $invocant && !Bugzilla->params->{'letsubmitterchoosepriority'}) {
+ $priority = Bugzilla->params->{'defaultpriority'};
+ }
+ return $invocant->_check_select_field($priority, 'priority');
+}
+
+sub _check_qa_contact {
+ my ($invocant, $qa_contact, undef, $params) = @_;
+ $qa_contact = trim($qa_contact) if !ref $qa_contact;
+ my $component = blessed($invocant) ? $invocant->component_obj
+ : $params->{component};
+ if (!ref $invocant) {
+ # Bugs get no QA Contact on creation if useqacontact is off.
+ return undef if !Bugzilla->params->{useqacontact};
+ # Set the default QA Contact if one isn't specified or if the
+ # user doesn't have editbugs.
+ if (!Bugzilla->user->in_group('editbugs', $component->product_id)
+ || !$qa_contact)
+ {
+ return $component->default_qa_contact ? $component->default_qa_contact->id : undef;
+ }
+ }
+
+ # If a QA Contact was specified or if we're updating, check
+ # the QA Contact for validity.
+ my $id;
+ if ($qa_contact) {
+ $qa_contact = Bugzilla::User->check($qa_contact) if !ref $qa_contact;
+ $id = $qa_contact->id;
+ # create() checks this another way, so we don't have to run this
+ # check during create().
+ # If there is no QA contact, this check is not required.
+ $invocant->_check_strict_isolation_for_user($qa_contact)
+ if (ref $invocant && $id);
+ }
+
+ # "0" always means "undef", for QA Contact.
+ return $id || undef;
+}
+
+sub _check_reporter {
+ my $invocant = shift;
+ my $reporter;
+ if (ref $invocant) {
+ # You cannot change the reporter of a bug.
+ $reporter = $invocant->reporter->id;
+ }
+ else {
+ # On bug creation, the reporter is the logged in user
+ # (meaning that he must be logged in first!).
+ Bugzilla->login(LOGIN_REQUIRED);
+ $reporter = Bugzilla->user->id;
+ }
+ return $reporter;
+}
+
+sub _check_resolution {
+ my ($invocant, $resolution, undef, $params) = @_;
+ $resolution = trim($resolution);
+ my $status = ref($invocant) ? $invocant->status->name
+ : $params->{bug_status};
+ my $is_open = ref($invocant) ? $invocant->status->is_open
+ : is_open_state($status);
+
+ # Throw a special error for resolving bugs without a resolution
+ # (or trying to change the resolution to '' on a closed bug without
+ # using clear_resolution).
+ ThrowUserError('missing_resolution', { status => $status })
+ if !$resolution && !$is_open;
+
+ # Make sure this is a valid resolution.
+ $resolution = $invocant->_check_select_field($resolution, 'resolution');
+
+ # Don't allow open bugs to have resolutions.
+ ThrowUserError('resolution_not_allowed') if $is_open;
+
+ # Check noresolveonopenblockers.
+ my $dependson = ref($invocant) ? $invocant->dependson
+ : ($params->{dependson} || []);
+ if (Bugzilla->params->{"noresolveonopenblockers"}
+ && $resolution eq 'FIXED'
+ && (!ref $invocant or !$invocant->resolution
+ or $resolution ne $invocant->resolution)
+ && scalar @$dependson)
+ {
+ my $dep_bugs = Bugzilla::Bug->new_from_list($dependson);
+ my $count_open = grep { $_->isopened } @$dep_bugs;
+ if ($count_open) {
+ my $bug_id = ref($invocant) ? $invocant->id : undef;
+ ThrowUserError("still_unresolved_bugs",
+ { bug_id => $bug_id, dep_count => $count_open });
+ }
+ }
+
+ # Check if they're changing the resolution and need to comment.
+ if (Bugzilla->params->{'commentonchange_resolution'}) {
+ $invocant->_check_commenton($resolution, 'resolution', $params);
+ }
+
+ return $resolution;
+}
+
+sub _check_short_desc {
+ my ($invocant, $short_desc) = @_;
+ # Set the parameter to itself, but cleaned up
+ $short_desc = clean_text($short_desc) if $short_desc;
+
+ if (!defined $short_desc || $short_desc eq '') {
+ ThrowUserError("require_summary");
+ }
+ if (length($short_desc) > MAX_FREETEXT_LENGTH) {
+ ThrowUserError('freetext_too_long',
+ { field => 'short_desc', text => $short_desc });
+ }
+ return $short_desc;
+}
+
+sub _check_status_whiteboard { return defined $_[1] ? $_[1] : ''; }
+
+# Unlike other checkers, this one doesn't return anything.
+sub _check_strict_isolation {
+ my ($invocant, $ccs, $assignee, $qa_contact, $product) = @_;
+ return unless Bugzilla->params->{'strict_isolation'};
+
+ if (ref $invocant) {
+ my $original = $invocant->new($invocant->id);
+
+ # We only check people if they've been added. This way, if
+ # strict_isolation is turned on when there are invalid users
+ # on bugs, people can still add comments and so on.
+ my @old_cc = map { $_->id } @{$original->cc_users};
+ my @new_cc = map { $_->id } @{$invocant->cc_users};
+ my ($removed, $added) = diff_arrays(\@old_cc, \@new_cc);
+ $ccs = Bugzilla::User->new_from_list($added);
+
+ $assignee = $invocant->assigned_to
+ if $invocant->assigned_to->id != $original->assigned_to->id;
+ if ($invocant->qa_contact
+ && (!$original->qa_contact
+ || $invocant->qa_contact->id != $original->qa_contact->id))
+ {
+ $qa_contact = $invocant->qa_contact;
+ }
+ $product = $invocant->product_obj;
+ }
+
+ my @related_users = @$ccs;
+ push(@related_users, $assignee) if $assignee;
+
+ if (Bugzilla->params->{'useqacontact'} && $qa_contact) {
+ push(@related_users, $qa_contact);
+ }
+
+ @related_users = @{Bugzilla::User->new_from_list(\@related_users)}
+ if !ref $invocant;
+
+ # For each unique user in @related_users...(assignee and qa_contact
+ # could be duplicates of users in the CC list)
+ my %unique_users = map {$_->id => $_} @related_users;
+ my @blocked_users;
+ foreach my $id (keys %unique_users) {
+ my $related_user = $unique_users{$id};
+ if (!$related_user->can_edit_product($product->id) ||
+ !$related_user->can_see_product($product->name)) {
+ push (@blocked_users, $related_user->login);
+ }
+ }
+ if (scalar(@blocked_users)) {
+ my %vars = ( users => \@blocked_users,
+ product => $product->name );
+ if (ref $invocant) {
+ $vars{'bug_id'} = $invocant->id;
+ }
+ else {
+ $vars{'new'} = 1;
+ }
+ ThrowUserError("invalid_user_group", \%vars);
+ }
+}
+
+# This is used by various set_ checkers, to make their code simpler.
+sub _check_strict_isolation_for_user {
+ my ($self, $user) = @_;
+ return unless Bugzilla->params->{"strict_isolation"};
+ if (!$user->can_edit_product($self->{product_id})) {
+ ThrowUserError('invalid_user_group',
+ { users => $user->login,
+ product => $self->product,
+ bug_id => $self->id });
+ }
+}
+
+sub _check_tag_name {
+ my ($invocant, $tag) = @_;
+
+ $tag = clean_text($tag);
+ $tag || ThrowUserError('no_tag_to_edit');
+ ThrowUserError('tag_name_too_long') if length($tag) > MAX_LEN_QUERY_NAME;
+ trick_taint($tag);
+ # Tags are all lowercase.
+ return lc($tag);
+}
+
+sub _check_target_milestone {
+ my ($invocant, $target, undef, $params) = @_;
+ my $product = blessed($invocant) ? $invocant->product_obj
+ : $params->{product};
+ my $old_target = blessed($invocant) ? $invocant->target_milestone : '';
+ $target = trim($target);
+ $target = $product->default_milestone if !defined $target;
+ my $object = Bugzilla::Milestone->check(
+ { product => $product, name => $target });
+ if ($old_target && $object->name ne $old_target && !$object->is_active) {
+ ThrowUserError('value_inactive', { class => ref($object), value => $target });
+ }
+ return $object->name;
+}
+
+sub _check_time_field {
+ my ($invocant, $value, $field, $params) = @_;
+
+ # When filing bugs, we're forgiving and just return 0 if
+ # the user isn't a timetracker. When updating bugs, check_can_change_field
+ # controls permissions, so we don't want to check them here.
+ if (!ref $invocant and !Bugzilla->user->is_timetracker) {
+ return 0;
+ }
+
+ # check_time is in Bugzilla::Object.
+ return $invocant->check_time($value, $field, $params);
+}
+
+sub _check_version {
+ my ($invocant, $version, undef, $params) = @_;
+ $version = trim($version);
+ my $product = blessed($invocant) ? $invocant->product_obj
+ : $params->{product};
+ my $old_vers = blessed($invocant) ? $invocant->version : '';
+ my $object = Bugzilla::Version->check({ product => $product, name => $version });
+ if ($object->name ne $old_vers && !$object->is_active) {
+ ThrowUserError('value_inactive', { class => ref($object), value => $version });
+ }
+ return $object->name;
+}
+
+# Custom Field Validators
+
+sub _check_field_is_mandatory {
+ my ($invocant, $value, $field, $params) = @_;
+
+ if (!blessed($field)) {
+ $field = Bugzilla::Field->new({ name => $field });
+ return if !$field;
+ }
+
+ return if !$field->is_mandatory;
+
+ return if !$field->is_visible_on_bug($params || $invocant);
+
+ return if ($field->type == FIELD_TYPE_SINGLE_SELECT
+ && scalar @{ get_legal_field_values($field->name) } == 1);
+
+ return if ($field->type == FIELD_TYPE_MULTI_SELECT
+ && !scalar @{ get_legal_field_values($field->name) });
+
+ if (ref($value) eq 'ARRAY') {
+ $value = join('', @$value);
+ }
+
+ $value = trim($value);
+ if (!defined($value)
+ or $value eq ""
+ or ($value eq '---' and $field->type == FIELD_TYPE_SINGLE_SELECT)
+ or ($value =~ EMPTY_DATETIME_REGEX
+ and $field->type == FIELD_TYPE_DATETIME))
+ {
+ ThrowUserError('required_field', { field => $field });
+ }
+}
+
+sub _check_datetime_field {
+ my ($invocant, $date_time) = @_;
+
+ # Empty datetimes are empty strings or strings only containing
+ # 0's, whitespace, and punctuation.
+ if ($date_time =~ /^[\s0[:punct:]]*$/) {
+ return undef;
+ }
+
+ $date_time = trim($date_time);
+ my ($date, $time) = split(' ', $date_time);
+ if ($date && !validate_date($date)) {
+ ThrowUserError('illegal_date', { date => $date,
+ format => 'YYYY-MM-DD' });
+ }
+ if ($time && !validate_time($time)) {
+ ThrowUserError('illegal_time', { 'time' => $time,
+ format => 'HH:MM:SS' });
+ }
+ return $date_time
+}
+
+sub _check_default_field { return defined $_[1] ? trim($_[1]) : ''; }
+
+sub _check_freetext_field {
+ my ($invocant, $text, $field) = @_;
+
+ $text = (defined $text) ? trim($text) : '';
+ if (length($text) > MAX_FREETEXT_LENGTH) {
+ ThrowUserError('freetext_too_long',
+ { field => $field, text => $text });
+ }
+ return $text;
+}
+
+sub _check_multi_select_field {
+ my ($invocant, $values, $field) = @_;
+
+ # Allow users (mostly email_in.pl) to specify multi-selects as
+ # comma-separated values.
+ if (defined $values and !ref $values) {
+ # We don't split on spaces because multi-select values can and often
+ # do have spaces in them. (Theoretically they can have commas in them
+ # too, but that's much less common and people should be able to work
+ # around it pretty cleanly, if they want to use email_in.pl.)
+ $values = [split(',', $values)];
+ }
+
+ return [] if !$values;
+ my @checked_values;
+ foreach my $value (@$values) {
+ push(@checked_values, $invocant->_check_select_field($value, $field));
+ }
+ return \@checked_values;
+}
+
+sub _check_select_field {
+ my ($invocant, $value, $field) = @_;
+ my $object = Bugzilla::Field::Choice->type($field)->check($value);
+ return $object->name;
+}
+
+sub _check_bugid_field {
+ my ($invocant, $value, $field) = @_;
+ return undef if !$value;
+
+ # check that the value is a valid, visible bug id
+ my $checked_id = $invocant->check($value, $field)->id;
+
+ # check for loop (can't have a loop if this is a new bug)
+ if (ref $invocant) {
+ _check_relationship_loop($field, $invocant->bug_id, $checked_id);
+ }
+
+ return $checked_id;
+}
+
+sub _check_textarea_field {
+ my ($invocant, $text, $field) = @_;
+
+ $text = (defined $text) ? trim($text) : '';
+
+ # Web browsers submit newlines as \r\n.
+ # Sanitize all input to match the web standard.
+ # XMLRPC input could be either \n or \r\n
+ $text =~ s/\r?\n/\r\n/g;
+
+ return $text;
+}
+
+sub _check_relationship_loop {
+ # Generates a dependency tree for a given bug. Calls itself recursively
+ # to generate sub-trees for the bug's dependencies.
+ my ($field, $bug_id, $dep_id, $ids) = @_;
+
+ # Don't do anything if this bug doesn't have any dependencies.
+ return unless defined($dep_id);
+
+ # Check whether we have seen this bug yet
+ $ids = {} unless defined $ids;
+ $ids->{$bug_id} = 1;
+ if ($ids->{$dep_id}) {
+ ThrowUserError("relationship_loop_single", {
+ 'bug_id' => $bug_id,
+ 'dep_id' => $dep_id,
+ 'field_name' => $field});
+ }
+
+ # Get this dependency's record from the database
+ my $dbh = Bugzilla->dbh;
+ my $next_dep_id = $dbh->selectrow_array(
+ "SELECT $field FROM bugs WHERE bug_id = ?", undef, $dep_id);
+
+ _check_relationship_loop($field, $dep_id, $next_dep_id, $ids);
+}
+
+#####################################################################
+# Class Accessors
+#####################################################################
+
+sub fields {
+ my $class = shift;
+
+ my @fields =
+ (
+ # Standard Fields
+ # Keep this ordering in sync with bugzilla.dtd.
+ qw(bug_id alias creation_ts short_desc delta_ts
+ reporter_accessible cclist_accessible
+ classification_id classification
+ product component version rep_platform op_sys
+ bug_status resolution dup_id see_also
+ bug_file_loc status_whiteboard keywords
+ priority bug_severity target_milestone
+ dependson blocked everconfirmed
+ reporter assigned_to cc estimated_time
+ remaining_time actual_time deadline),
+
+ # Conditional Fields
+ Bugzilla->params->{'useqacontact'} ? "qa_contact" : (),
+ # Custom Fields
+ map { $_->name } Bugzilla->active_custom_fields
+ );
+ Bugzilla::Hook::process('bug_fields', {'fields' => \@fields} );
+
+ return @fields;
+}
+
+#####################################################################
+# Mutators
+#####################################################################
+
+# To run check_can_change_field.
+sub _set_global_validator {
+ my ($self, $value, $field) = @_;
+ my $current = $self->$field;
+ my $privs;
+
+ if (ref $current && ref($current) ne 'ARRAY'
+ && $current->isa('Bugzilla::Object')) {
+ $current = $current->id ;
+ }
+ if (ref $value && ref($value) ne 'ARRAY'
+ && $value->isa('Bugzilla::Object')) {
+ $value = $value->id ;
+ }
+ my $can = $self->check_can_change_field($field, $current, $value, \$privs);
+ if (!$can) {
+ if ($field eq 'assigned_to' || $field eq 'qa_contact') {
+ $value = user_id_to_login($value);
+ $current = user_id_to_login($current);
+ }
+ ThrowUserError('illegal_change', { field => $field,
+ oldvalue => $current,
+ newvalue => $value,
+ privs => $privs });
+ }
+ $self->_check_field_is_mandatory($value, $field);
+}
+
+
+#################
+# "Set" Methods #
+#################
+
+# Note that if you are changing multiple bugs at once, you must pass
+# other_bugs to set_all in order for it to behave properly.
+sub set_all {
+ my $self = shift;
+ my ($input_params) = @_;
+
+ # Clone the data as we are going to alter it, and this would affect
+ # subsequent bugs when calling set_all() again, as some fields would
+ # be modified or no longer defined.
+ my $params = {};
+ %$params = %$input_params;
+
+ # You cannot mark bugs as duplicate when changing several bugs at once
+ # (because currently there is no way to check for duplicate loops in that
+ # situation). You also cannot set the alias of several bugs at once.
+ if ($params->{other_bugs} and scalar @{ $params->{other_bugs} } > 1) {
+ ThrowUserError('dupe_not_allowed') if exists $params->{dup_id};
+ ThrowUserError('multiple_alias_not_allowed')
+ if defined $params->{alias};
+ }
+
+ # For security purposes, and because lots of other checks depend on it,
+ # we set the product first before anything else.
+ my $product_changed; # Used only for strict_isolation checks.
+ if (exists $params->{'product'}) {
+ $product_changed = $self->_set_product($params->{'product'}, $params);
+ }
+
+ # strict_isolation checks mean that we should set the groups
+ # immediately after changing the product.
+ $self->_add_remove($params, 'groups');
+
+ if (exists $params->{'dependson'} or exists $params->{'blocked'}) {
+ my %set_deps;
+ foreach my $name (qw(dependson blocked)) {
+ my @dep_ids = @{ $self->$name };
+ # If only one of the two fields was passed in, then we need to
+ # retain the current value for the other one.
+ if (!exists $params->{$name}) {
+ $set_deps{$name} = \@dep_ids;
+ next;
+ }
+
+ # Explicitly setting them to a particular value overrides
+ # add/remove.
+ if (exists $params->{$name}->{set}) {
+ $set_deps{$name} = $params->{$name}->{set};
+ next;
+ }
+
+ foreach my $add (@{ $params->{$name}->{add} || [] }) {
+ push(@dep_ids, $add) if !grep($_ == $add, @dep_ids);
+ }
+ foreach my $remove (@{ $params->{$name}->{remove} || [] }) {
+ @dep_ids = grep($_ != $remove, @dep_ids);
+ }
+ $set_deps{$name} = \@dep_ids;
+ }
+
+ $self->set_dependencies($set_deps{'dependson'}, $set_deps{'blocked'});
+ }
+
+ if (exists $params->{'keywords'}) {
+ # Sorting makes the order "add, remove, set", just like for other
+ # fields.
+ foreach my $action (sort keys %{ $params->{'keywords'} }) {
+ $self->modify_keywords($params->{'keywords'}->{$action}, $action);
+ }
+ }
+
+ if (exists $params->{'comment'} or exists $params->{'work_time'}) {
+ # Add a comment as needed to each bug. This is done early because
+ # there are lots of things that want to check if we added a comment.
+ $self->add_comment($params->{'comment'}->{'body'},
+ { isprivate => $params->{'comment'}->{'is_private'},
+ work_time => $params->{'work_time'} });
+ }
+
+ my %normal_set_all;
+ foreach my $name (keys %$params) {
+ # These are handled separately below.
+ if ($self->can("set_$name")) {
+ $normal_set_all{$name} = $params->{$name};
+ }
+ }
+ $self->SUPER::set_all(\%normal_set_all);
+
+ $self->reset_assigned_to if $params->{'reset_assigned_to'};
+ $self->reset_qa_contact if $params->{'reset_qa_contact'};
+
+ $self->_add_remove($params, 'see_also');
+
+ # And set custom fields.
+ my @custom_fields = Bugzilla->active_custom_fields;
+ foreach my $field (@custom_fields) {
+ my $fname = $field->name;
+ if (exists $params->{$fname}) {
+ $self->set_custom_field($field, $params->{$fname});
+ }
+ }
+
+ $self->_add_remove($params, 'cc');
+
+ # Theoretically you could move a product without ever specifying
+ # a new assignee or qa_contact, or adding/removing any CCs. So,
+ # we have to check that the current assignee, qa, and CCs are still
+ # valid if we've switched products, under strict_isolation. We can only
+ # do that here, because if they *did* change the assignee, qa, or CC,
+ # then we don't want to check the original ones, only the new ones.
+ $self->_check_strict_isolation() if $product_changed;
+}
+
+# Helper for set_all that helps with fields that have an "add/remove"
+# pattern instead of a "set_" pattern.
+sub _add_remove {
+ my ($self, $params, $name) = @_;
+ my @add = @{ $params->{$name}->{add} || [] };
+ my @remove = @{ $params->{$name}->{remove} || [] };
+ $name =~ s/s$//;
+ my $add_method = "add_$name";
+ my $remove_method = "remove_$name";
+ $self->$add_method($_) foreach @add;
+ $self->$remove_method($_) foreach @remove;
+}
+
+sub set_alias { $_[0]->set('alias', $_[1]); }
+sub set_assigned_to {
+ my ($self, $value) = @_;
+ $self->set('assigned_to', $value);
+ # Store the old assignee. check_can_change_field() needs it.
+ $self->{'_old_assigned_to'} = $self->{'assigned_to_obj'}->id;
+ delete $self->{'assigned_to_obj'};
+}
+sub reset_assigned_to {
+ my $self = shift;
+ my $comp = $self->component_obj;
+ $self->set_assigned_to($comp->default_assignee);
+}
+sub set_cclist_accessible { $_[0]->set('cclist_accessible', $_[1]); }
+sub set_comment_is_private {
+ my ($self, $comment_id, $isprivate) = @_;
+
+ # We also allow people to pass in a hash of comment ids to update.
+ if (ref $comment_id) {
+ while (my ($id, $is) = each %$comment_id) {
+ $self->set_comment_is_private($id, $is);
+ }
+ return;
+ }
+
+ my ($comment) = grep($comment_id == $_->id, @{ $self->comments });
+ ThrowUserError('comment_invalid_isprivate', { id => $comment_id })
+ if !$comment;
+
+ $isprivate = $isprivate ? 1 : 0;
+ if ($isprivate != $comment->is_private) {
+ ThrowUserError('user_not_insider') if !Bugzilla->user->is_insider;
+ $self->{comment_isprivate} ||= [];
+ $comment->set_is_private($isprivate);
+ push @{$self->{comment_isprivate}}, $comment;
+ }
+}
+sub set_component {
+ my ($self, $name) = @_;
+ my $old_comp = $self->component_obj;
+ my $component = $self->_check_component($name);
+ if ($old_comp->id != $component->id) {
+ $self->{component_id} = $component->id;
+ $self->{component} = $component->name;
+ $self->{component_obj} = $component;
+ # For update()
+ $self->{_old_component_name} = $old_comp->name;
+ # Add in the Default CC of the new Component;
+ foreach my $cc (@{$component->initial_cc}) {
+ $self->add_cc($cc);
+ }
+ }
+}
+sub set_custom_field {
+ my ($self, $field, $value) = @_;
+
+ if (ref $value eq 'ARRAY' && $field->type != FIELD_TYPE_MULTI_SELECT) {
+ $value = $value->[0];
+ }
+ ThrowCodeError('field_not_custom', { field => $field }) if !$field->custom;
+ $self->set($field->name, $value);
+}
+sub set_deadline { $_[0]->set('deadline', $_[1]); }
+sub set_dependencies {
+ my ($self, $dependson, $blocked) = @_;
+ my %extra = ( blocked => $blocked );
+ $dependson = $self->_check_dependencies($dependson, 'dependson', \%extra);
+ $blocked = $extra{blocked};
+ # These may already be detainted, but all setters are supposed to
+ # detaint their input if they've run a validator (just as though
+ # we had used Bugzilla::Object::set), so we do that here.
+ detaint_natural($_) foreach (@$dependson, @$blocked);
+ $self->{'dependson'} = $dependson;
+ $self->{'blocked'} = $blocked;
+ delete $self->{depends_on_obj};
+ delete $self->{blocks_obj};
+}
+sub _clear_dup_id { $_[0]->{dup_id} = undef; }
+sub set_dup_id {
+ my ($self, $dup_id) = @_;
+ my $old = $self->dup_id || 0;
+ $self->set('dup_id', $dup_id);
+ my $new = $self->dup_id;
+ return if $old == $new;
+
+ # Make sure that we have the DUPLICATE resolution. This is needed
+ # if somebody calls set_dup_id without calling set_bug_status or
+ # set_resolution.
+ if ($self->resolution ne 'DUPLICATE') {
+ # Even if the current status is VERIFIED, we change it back to
+ # RESOLVED (or whatever the duplicate_or_move_bug_status is) here,
+ # because that's the same thing the UI does when you click on the
+ # "Mark as Duplicate" link. If people really want to retain their
+ # current status, they can use set_bug_status and set the DUPLICATE
+ # resolution before getting here.
+ $self->set_bug_status(
+ Bugzilla->params->{'duplicate_or_move_bug_status'},
+ { resolution => 'DUPLICATE' });
+ }
+
+ # Update the other bug.
+ my $dupe_of = new Bugzilla::Bug($self->dup_id);
+ if (delete $self->{_add_dup_cc}) {
+ $dupe_of->add_cc($self->reporter);
+ }
+ $dupe_of->add_comment("", { type => CMT_HAS_DUPE,
+ extra_data => $self->id });
+ $self->{_dup_for_update} = $dupe_of;
+
+ # Now make sure that we add a duplicate comment on *this* bug.
+ # (Change an existing comment into a dup comment, if there is one,
+ # or add an empty dup comment.)
+ if ($self->{added_comments}) {
+ my @normal = grep { !defined $_->{type} || $_->{type} == CMT_NORMAL }
+ @{ $self->{added_comments} };
+ # Turn the last one into a dup comment.
+ $normal[-1]->{type} = CMT_DUPE_OF;
+ $normal[-1]->{extra_data} = $self->dup_id;
+ }
+ else {
+ $self->add_comment('', { type => CMT_DUPE_OF,
+ extra_data => $self->dup_id });
+ }
+}
+sub set_estimated_time { $_[0]->set('estimated_time', $_[1]); }
+sub _set_everconfirmed { $_[0]->set('everconfirmed', $_[1]); }
+sub set_flags {
+ my ($self, $flags, $new_flags) = @_;
+
+ Bugzilla::Flag->set_flag($self, $_) foreach (@$flags, @$new_flags);
+}
+sub set_op_sys { $_[0]->set('op_sys', $_[1]); }
+sub set_platform { $_[0]->set('rep_platform', $_[1]); }
+sub set_priority { $_[0]->set('priority', $_[1]); }
+# For security reasons, you have to use set_all to change the product.
+# See the strict_isolation check in set_all for an explanation.
+sub _set_product {
+ my ($self, $name, $params) = @_;
+ my $old_product = $self->product_obj;
+ my $product = $self->_check_product($name);
+
+ my $product_changed = 0;
+ if ($old_product->id != $product->id) {
+ $self->{product_id} = $product->id;
+ $self->{product} = $product->name;
+ $self->{product_obj} = $product;
+ # For update()
+ $self->{_old_product_name} = $old_product->name;
+ # Delete fields that depend upon the old Product value.
+ delete $self->{choices};
+ $product_changed = 1;
+ }
+
+ $params ||= {};
+ # We delete these so that they're not set again later in set_all.
+ my $comp_name = delete $params->{component} || $self->component;
+ my $vers_name = delete $params->{version} || $self->version;
+ my $tm_name = delete $params->{target_milestone};
+ # This way, if usetargetmilestone is off and we've changed products,
+ # set_target_milestone will reset our target_milestone to
+ # $product->default_milestone. But if we haven't changed products,
+ # we don't reset anything.
+ if (!defined $tm_name
+ && (Bugzilla->params->{'usetargetmilestone'} || !$product_changed))
+ {
+ $tm_name = $self->target_milestone;
+ }
+
+ if ($product_changed && Bugzilla->usage_mode == USAGE_MODE_BROWSER) {
+ # Try to set each value with the new product.
+ # Have to set error_mode because Throw*Error calls exit() otherwise.
+ my $old_error_mode = Bugzilla->error_mode;
+ Bugzilla->error_mode(ERROR_MODE_DIE);
+ my $component_ok = eval { $self->set_component($comp_name); 1; };
+ my $version_ok = eval { $self->set_version($vers_name); 1; };
+ my $milestone_ok = 1;
+ # Reporters can move bugs between products but not set the TM.
+ if ($self->check_can_change_field('target_milestone', 0, 1)) {
+ $milestone_ok = eval { $self->set_target_milestone($tm_name); 1; };
+ }
+ else {
+ # Have to set this directly to bypass the validators.
+ $self->{target_milestone} = $product->default_milestone;
+ }
+ # If there were any errors thrown, make sure we don't mess up any
+ # other part of Bugzilla that checks $@.
+ undef $@;
+ Bugzilla->error_mode($old_error_mode);
+
+ my $verified = $params->{product_change_confirmed};
+ my %vars;
+ if (!$verified || !$component_ok || !$version_ok || !$milestone_ok) {
+ $vars{defaults} = {
+ # Note that because of the eval { set } above, these are
+ # already set correctly if they're valid, otherwise they're
+ # set to some invalid value which the template will ignore.
+ component => $self->component,
+ version => $self->version,
+ milestone => $milestone_ok ? $self->target_milestone
+ : $product->default_milestone
+ };
+ $vars{components} = [map { $_->name } grep($_->is_active, @{$product->components})];
+ $vars{milestones} = [map { $_->name } grep($_->is_active, @{$product->milestones})];
+ $vars{versions} = [map { $_->name } grep($_->is_active, @{$product->versions})];
+ }
+
+ if (!$verified) {
+ $vars{verify_bug_groups} = 1;
+ my $dbh = Bugzilla->dbh;
+ my @idlist = ($self->id);
+ push(@idlist, map {$_->id} @{ $params->{other_bugs} })
+ if $params->{other_bugs};
+ @idlist = uniq @idlist;
+ # Get the ID of groups which are no longer valid in the new product.
+ my $gids = $dbh->selectcol_arrayref(
+ 'SELECT bgm.group_id
+ FROM bug_group_map AS bgm
+ WHERE bgm.bug_id IN (' . join(',', ('?') x @idlist) . ')
+ AND bgm.group_id NOT IN
+ (SELECT gcm.group_id
+ FROM group_control_map AS gcm
+ WHERE gcm.product_id = ?
+ AND ( (gcm.membercontrol != ?
+ AND gcm.group_id IN ('
+ . Bugzilla->user->groups_as_string . '))
+ OR gcm.othercontrol != ?) )',
+ undef, (@idlist, $product->id, CONTROLMAPNA, CONTROLMAPNA));
+ $vars{'old_groups'} = Bugzilla::Group->new_from_list($gids);
+
+ # Did we come here from editing multiple bugs? (affects how we
+ # show optional group changes)
+ $vars{multiple_bugs} = (@idlist > 1) ? 1 : 0;
+ }
+
+ if (%vars) {
+ $vars{product} = $product;
+ $vars{bug} = $self;
+ my $template = Bugzilla->template;
+ $template->process("bug/process/verify-new-product.html.tmpl",
+ \%vars) || ThrowTemplateError($template->error());
+ exit;
+ }
+ }
+ else {
+ # When we're not in the browser (or we didn't change the product), we
+ # just die if any of these are invalid.
+ $self->set_component($comp_name);
+ $self->set_version($vers_name);
+ if ($product_changed
+ and !$self->check_can_change_field('target_milestone', 0, 1))
+ {
+ # Have to set this directly to bypass the validators.
+ $self->{target_milestone} = $product->default_milestone;
+ }
+ else {
+ $self->set_target_milestone($tm_name);
+ }
+ }
+
+ if ($product_changed) {
+ # Remove groups that can't be set in the new product.
+ # We copy this array because the original array is modified while we're
+ # working, and that confuses "foreach".
+ my @current_groups = @{$self->groups_in};
+ foreach my $group (@current_groups) {
+ if (!$product->group_is_valid($group)) {
+ $self->remove_group($group);
+ }
+ }
+
+ # Make sure the bug is in all the mandatory groups for the new product.
+ foreach my $group (@{$product->groups_mandatory}) {
+ $self->add_group($group);
+ }
+ }
+
+ return $product_changed;
+}
+
+sub set_qa_contact {
+ my ($self, $value) = @_;
+ $self->set('qa_contact', $value);
+ # Store the old QA contact. check_can_change_field() needs it.
+ if ($self->{'qa_contact_obj'}) {
+ $self->{'_old_qa_contact'} = $self->{'qa_contact_obj'}->id;
+ }
+ delete $self->{'qa_contact_obj'};
+}
+sub reset_qa_contact {
+ my $self = shift;
+ my $comp = $self->component_obj;
+ $self->set_qa_contact($comp->default_qa_contact);
+}
+sub set_remaining_time { $_[0]->set('remaining_time', $_[1]); }
+# Used only when closing a bug or moving between closed states.
+sub _zero_remaining_time { $_[0]->{'remaining_time'} = 0; }
+sub set_reporter_accessible { $_[0]->set('reporter_accessible', $_[1]); }
+sub set_resolution {
+ my ($self, $value, $params) = @_;
+
+ my $old_res = $self->resolution;
+ $self->set('resolution', $value);
+ delete $self->{choices};
+ my $new_res = $self->resolution;
+
+ if ($new_res ne $old_res) {
+ # Clear the dup_id if we're leaving the dup resolution.
+ if ($old_res eq 'DUPLICATE') {
+ $self->_clear_dup_id();
+ }
+ # Duplicates should have no remaining time left.
+ elsif ($new_res eq 'DUPLICATE' && $self->remaining_time != 0) {
+ $self->_zero_remaining_time();
+ }
+ }
+
+ # We don't check if we're entering or leaving the dup resolution here,
+ # because we could be moving from being a dup of one bug to being a dup
+ # of another, theoretically. Note that this code block will also run
+ # when going between different closed states.
+ if ($self->resolution eq 'DUPLICATE') {
+ if (my $dup_id = $params->{dup_id}) {
+ $self->set_dup_id($dup_id);
+ }
+ elsif (!$self->dup_id) {
+ ThrowUserError('dupe_id_required');
+ }
+ }
+
+ # This method has handled dup_id, so set_all doesn't have to worry
+ # about it now.
+ delete $params->{dup_id};
+}
+sub clear_resolution {
+ my $self = shift;
+ if (!$self->status->is_open) {
+ ThrowUserError('resolution_cant_clear', { bug_id => $self->id });
+ }
+ $self->{'resolution'} = '';
+ $self->_clear_dup_id;
+}
+sub set_severity { $_[0]->set('bug_severity', $_[1]); }
+sub set_bug_status {
+ my ($self, $status, $params) = @_;
+ my $old_status = $self->status;
+ $self->set('bug_status', $status);
+ delete $self->{'status'};
+ delete $self->{'statuses_available'};
+ delete $self->{'choices'};
+ my $new_status = $self->status;
+
+ if ($new_status->is_open) {
+ # Check for the everconfirmed transition
+ $self->_set_everconfirmed($new_status->name eq 'UNCONFIRMED' ? 0 : 1);
+ $self->clear_resolution();
+ # Calling clear_resolution handled the "resolution" and "dup_id"
+ # setting, so set_all doesn't have to worry about them.
+ delete $params->{resolution};
+ delete $params->{dup_id};
+ }
+ else {
+ # We do this here so that we can make sure closed statuses have
+ # resolutions.
+ my $resolution = $self->resolution;
+ # We need to check "defined" to prevent people from passing
+ # a blank resolution in the WebService, which would otherwise fail
+ # silently.
+ if (defined $params->{resolution}) {
+ $resolution = delete $params->{resolution};
+ }
+ $self->set_resolution($resolution, $params);
+
+ # Changing between closed statuses zeros the remaining time.
+ if ($new_status->id != $old_status->id && $self->remaining_time != 0) {
+ $self->_zero_remaining_time();
+ }
+ }
+}
+sub set_status_whiteboard { $_[0]->set('status_whiteboard', $_[1]); }
+sub set_summary { $_[0]->set('short_desc', $_[1]); }
+sub set_target_milestone { $_[0]->set('target_milestone', $_[1]); }
+sub set_url { $_[0]->set('bug_file_loc', $_[1]); }
+sub set_version { $_[0]->set('version', $_[1]); }
+
+########################
+# "Add/Remove" Methods #
+########################
+
+# These are in alphabetical order by field name.
+
+# Accepts a User object or a username. Adds the user only if they
+# don't already exist as a CC on the bug.
+sub add_cc {
+ my ($self, $user_or_name) = @_;
+ return if !$user_or_name;
+ my $user = ref $user_or_name ? $user_or_name
+ : Bugzilla::User->check($user_or_name);
+ $self->_check_strict_isolation_for_user($user);
+ my $cc_users = $self->cc_users;
+ push(@$cc_users, $user) if !grep($_->id == $user->id, @$cc_users);
+}
+
+# Accepts a User object or a username. Removes the User if they exist
+# in the list, but doesn't throw an error if they don't exist.
+sub remove_cc {
+ my ($self, $user_or_name) = @_;
+ my $user = ref $user_or_name ? $user_or_name
+ : Bugzilla::User->check($user_or_name);
+ my $currentUser = Bugzilla->user;
+ if (!$self->user->{'canedit'} && $user->id != $currentUser->id) {
+ ThrowUserError('cc_remove_denied');
+ }
+ my $cc_users = $self->cc_users;
+ @$cc_users = grep { $_->id != $user->id } @$cc_users;
+}
+
+# $bug->add_comment("comment", {isprivate => 1, work_time => 10.5,
+# type => CMT_NORMAL, extra_data => $data});
+sub add_comment {
+ my ($self, $comment, $params) = @_;
+
+ $params ||= {};
+
+ # Fill out info that doesn't change and callers may not pass in
+ $params->{'bug_id'} = $self;
+ $params->{'thetext'} = defined($comment) ? $comment : '';
+
+ # Validate all the entered data
+ Bugzilla::Comment->check_required_create_fields($params);
+ $params = Bugzilla::Comment->run_create_validators($params);
+
+ # This makes it so we won't create new comments when there is nothing
+ # to add
+ if ($params->{'thetext'} eq ''
+ && !($params->{type} || abs($params->{work_time} || 0)))
+ {
+ return;
+ }
+
+ # If the user has explicitly set remaining_time, this will be overridden
+ # later in set_all. But if they haven't, this keeps remaining_time
+ # up-to-date.
+ if ($params->{work_time}) {
+ $self->set_remaining_time(max($self->remaining_time - $params->{work_time}, 0));
+ }
+
+ $self->{added_comments} ||= [];
+
+ push(@{$self->{added_comments}}, $params);
+}
+
+sub modify_keywords {
+ my ($self, $keywords, $action) = @_;
+
+ if (!$action || !grep { $action eq $_ } qw(add remove set)) {
+ $action = 'set';
+ }
+
+ $keywords = $self->_check_keywords($keywords);
+ my @old_keywords = @{ $self->keyword_objects };
+ my @result;
+
+ if ($action eq 'set') {
+ @result = @$keywords;
+ }
+ else {
+ # We're adding or deleting specific keywords.
+ my %keys = map { $_->id => $_ } @old_keywords;
+ if ($action eq 'add') {
+ $keys{$_->id} = $_ foreach @$keywords;
+ }
+ else {
+ delete $keys{$_->id} foreach @$keywords;
+ }
+ @result = values %keys;
+ }
+
+ # Check if anything was added or removed.
+ my @old_ids = map { $_->id } @old_keywords;
+ my @new_ids = map { $_->id } @result;
+ my ($removed, $added) = diff_arrays(\@old_ids, \@new_ids);
+ my $any_changes = scalar @$removed || scalar @$added;
+
+ # Make sure we retain the sort order.
+ @result = sort {lc($a->name) cmp lc($b->name)} @result;
+
+ if ($any_changes) {
+ my $privs;
+ my $new = join(', ', (map {$_->name} @result));
+ my $check = $self->check_can_change_field('keywords', 0, 1, \$privs)
+ || ThrowUserError('illegal_change', { field => 'keywords',
+ oldvalue => $self->keywords,
+ newvalue => $new,
+ privs => $privs });
+ }
+
+ $self->{'keyword_objects'} = \@result;
+}
+
+sub add_group {
+ my ($self, $group) = @_;
+
+ # If the user enters "FoO" but the DB has "Foo", $group->name would
+ # return "Foo" and thus revealing the existence of the group name.
+ # So we have to store and pass the name as entered by the user to
+ # the error message, if we have it.
+ my $group_name = blessed($group) ? $group->name : $group;
+ my $args = { name => $group_name, product => $self->product,
+ bug_id => $self->id, action => 'add' };
+
+ $group = Bugzilla::Group->check_no_disclose($args) if !blessed $group;
+
+ # If the bug is already in this group, then there is nothing to do.
+ return if $self->in_group($group);
+
+
+ # Make sure that bugs in this product can actually be restricted
+ # to this group by the current user.
+ $self->product_obj->group_is_settable($group)
+ || ThrowUserError('group_restriction_not_allowed', $args);
+
+ # OtherControl people can add groups only during a product change,
+ # and only when the group is not NA for them.
+ if (!Bugzilla->user->in_group($group->name)) {
+ my $controls = $self->product_obj->group_controls->{$group->id};
+ if (!$self->{_old_product_name}
+ || $controls->{othercontrol} == CONTROLMAPNA)
+ {
+ ThrowUserError('group_restriction_not_allowed', $args);
+ }
+ }
+
+ my $current_groups = $self->groups_in;
+ push(@$current_groups, $group);
+}
+
+sub remove_group {
+ my ($self, $group) = @_;
+
+ # See add_group() for the reason why we store the user input.
+ my $group_name = blessed($group) ? $group->name : $group;
+ my $args = { name => $group_name, product => $self->product,
+ bug_id => $self->id, action => 'remove' };
+
+ $group = Bugzilla::Group->check_no_disclose($args) if !blessed $group;
+
+ # If the bug isn't in this group, then either the name is misspelled,
+ # or the group really doesn't exist. Let the user know about this problem.
+ $self->in_group($group) || ThrowUserError('group_invalid_removal', $args);
+
+ # Check if this is a valid group for this product. You can *always*
+ # remove a group that is not valid for this product (set_product does this).
+ # This particularly happens when we're moving a bug to a new product.
+ # You still have to be a member of an inactive group to remove it.
+ if ($self->product_obj->group_is_valid($group)) {
+ my $controls = $self->product_obj->group_controls->{$group->id};
+
+ # Nobody can ever remove a Mandatory group, unless it became inactive.
+ if ($controls->{membercontrol} == CONTROLMAPMANDATORY && $group->is_active) {
+ ThrowUserError('group_invalid_removal', $args);
+ }
+
+ # OtherControl people can remove groups only during a product change,
+ # and only when they are non-Mandatory and non-NA.
+ if (!Bugzilla->user->in_group($group->name)) {
+ if (!$self->{_old_product_name}
+ || $controls->{othercontrol} == CONTROLMAPMANDATORY
+ || $controls->{othercontrol} == CONTROLMAPNA)
+ {
+ ThrowUserError('group_invalid_removal', $args);
+ }
+ }
+ }
+
+ my $current_groups = $self->groups_in;
+ @$current_groups = grep { $_->id != $group->id } @$current_groups;
+}
+
+sub add_see_also {
+ my ($self, $input, $skip_recursion) = @_;
+
+ # This is needed by xt/search.t.
+ $input = $input->name if blessed($input);
+
+ $input = trim($input);
+ return if !$input;
+
+ my ($class, $uri) = Bugzilla::BugUrl->class_for($input);
+
+ my $params = { value => $uri, bug_id => $self, class => $class };
+ $class->check_required_create_fields($params);
+
+ my $field_values = $class->run_create_validators($params);
+ my $value = $field_values->{value}->as_string;
+ trick_taint($value);
+ $field_values->{value} = $value;
+
+ # We only add the new URI if it hasn't been added yet. URIs are
+ # case-sensitive, but most of our DBs are case-insensitive, so we do
+ # this check case-insensitively.
+ if (!grep { lc($_->name) eq lc($value) } @{ $self->see_also }) {
+ my $privs;
+ my $can = $self->check_can_change_field('see_also', '', $value, \$privs);
+ if (!$can) {
+ ThrowUserError('illegal_change', { field => 'see_also',
+ newvalue => $value,
+ privs => $privs });
+ }
+ # If this is a link to a local bug then save the
+ # ref bug id for sending changes email.
+ my $ref_bug = delete $field_values->{ref_bug};
+ if ($class->isa('Bugzilla::BugUrl::Bugzilla::Local')
+ and !$skip_recursion
+ and $ref_bug->check_can_change_field('see_also', '', $self->id, \$privs))
+ {
+ $ref_bug->add_see_also($self->id, 'skip_recursion');
+ push @{ $self->{_update_ref_bugs} }, $ref_bug;
+ push @{ $self->{see_also_changes} }, $ref_bug->id;
+ }
+ push @{ $self->{see_also} }, bless ($field_values, $class);
+ }
+}
+
+sub remove_see_also {
+ my ($self, $url, $skip_recursion) = @_;
+ my $see_also = $self->see_also;
+
+ # This is needed by xt/search.t.
+ $url = $url->name if blessed($url);
+
+ my ($removed_bug_url, $new_see_also) =
+ part { lc($_->name) ne lc($url) } @$see_also;
+
+ my $privs;
+ my $can = $self->check_can_change_field('see_also', $see_also, $new_see_also, \$privs);
+ if (!$can) {
+ ThrowUserError('illegal_change', { field => 'see_also',
+ oldvalue => $url,
+ privs => $privs });
+ }
+
+ # Since we remove also the url from the referenced bug,
+ # we need to notify changes for that bug too.
+ $removed_bug_url = $removed_bug_url->[0];
+ if (!$skip_recursion and $removed_bug_url
+ and $removed_bug_url->isa('Bugzilla::BugUrl::Bugzilla::Local')
+ and $removed_bug_url->ref_bug_url)
+ {
+ my $ref_bug
+ = Bugzilla::Bug->check($removed_bug_url->ref_bug_url->bug_id);
+
+ if (Bugzilla->user->can_edit_product($ref_bug->product_id)
+ and $ref_bug->check_can_change_field('see_also', $self->id, '', \$privs))
+ {
+ my $self_url = $removed_bug_url->local_uri($self->id);
+ $ref_bug->remove_see_also($self_url, 'skip_recursion');
+ push @{ $self->{_update_ref_bugs} }, $ref_bug;
+ push @{ $self->{see_also_changes} }, $ref_bug->id;
+ }
+ }
+
+ $self->{see_also} = $new_see_also || [];
+}
+
+sub add_tag {
+ my ($self, $tag) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+ $tag = $self->_check_tag_name($tag);
+
+ my $tag_id = $user->tags->{$tag}->{id};
+ # If this tag doesn't exist for this user yet, create it.
+ if (!$tag_id) {
+ $dbh->do('INSERT INTO tag (user_id, name) VALUES (?, ?)',
+ undef, ($user->id, $tag));
+
+ $tag_id = $dbh->selectrow_array('SELECT id FROM tag
+ WHERE name = ? AND user_id = ?',
+ undef, ($tag, $user->id));
+ # The list has changed.
+ delete $user->{tags};
+ }
+ # Do nothing if this tag is already set for this bug.
+ return if grep { $_ eq $tag } @{$self->tags};
+
+ # Increment the counter. Do it before the SQL call below,
+ # to not count the tag twice.
+ $user->tags->{$tag}->{bug_count}++;
+
+ $dbh->do('INSERT INTO bug_tag (bug_id, tag_id) VALUES (?, ?)',
+ undef, ($self->id, $tag_id));
+
+ push(@{$self->{tags}}, $tag);
+}
+
+sub remove_tag {
+ my ($self, $tag) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+ $tag = $self->_check_tag_name($tag);
+
+ my $tag_id = exists $user->tags->{$tag} ? $user->tags->{$tag}->{id} : undef;
+ # Do nothing if the user doesn't use this tag, or didn't set it for this bug.
+ return unless ($tag_id && grep { $_ eq $tag } @{$self->tags});
+
+ $dbh->do('DELETE FROM bug_tag WHERE bug_id = ? AND tag_id = ?',
+ undef, ($self->id, $tag_id));
+
+ $self->{tags} = [grep { $_ ne $tag } @{$self->tags}];
+
+ # Decrement the counter, and delete the tag if no bugs are using it anymore.
+ if (!--$user->tags->{$tag}->{bug_count}) {
+ $dbh->do('DELETE FROM tag WHERE name = ? AND user_id = ?',
+ undef, ($tag, $user->id));
+
+ # The list has changed.
+ delete $user->{tags};
+ }
+}
+
+sub tags {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+
+ # This method doesn't support several users using the same bug object.
+ if (!exists $self->{tags}) {
+ $self->{tags} = $dbh->selectcol_arrayref(
+ 'SELECT name FROM bug_tag
+ INNER JOIN tag ON tag.id = bug_tag.tag_id
+ WHERE bug_id = ? AND user_id = ?',
+ undef, ($self->id, $user->id));
+ }
+ return $self->{tags};
+}
+
+#####################################################################
+# Simple Accessors
+#####################################################################
+
+# These are accessors that don't need to access the database.
+# Keep them in alphabetical order.
+
+sub alias { return $_[0]->{alias} }
+sub bug_file_loc { return $_[0]->{bug_file_loc} }
+sub bug_id { return $_[0]->{bug_id} }
+sub bug_severity { return $_[0]->{bug_severity} }
+sub bug_status { return $_[0]->{bug_status} }
+sub cclist_accessible { return $_[0]->{cclist_accessible} }
+sub component_id { return $_[0]->{component_id} }
+sub creation_ts { return $_[0]->{creation_ts} }
+sub estimated_time { return $_[0]->{estimated_time} }
+sub deadline { return $_[0]->{deadline} }
+sub delta_ts { return $_[0]->{delta_ts} }
+sub error { return $_[0]->{error} }
+sub everconfirmed { return $_[0]->{everconfirmed} }
+sub lastdiffed { return $_[0]->{lastdiffed} }
+sub op_sys { return $_[0]->{op_sys} }
+sub priority { return $_[0]->{priority} }
+sub product_id { return $_[0]->{product_id} }
+sub remaining_time { return $_[0]->{remaining_time} }
+sub reporter_accessible { return $_[0]->{reporter_accessible} }
+sub rep_platform { return $_[0]->{rep_platform} }
+sub resolution { return $_[0]->{resolution} }
+sub short_desc { return $_[0]->{short_desc} }
+sub status_whiteboard { return $_[0]->{status_whiteboard} }
+sub target_milestone { return $_[0]->{target_milestone} }
+sub version { return $_[0]->{version} }
+
+#####################################################################
+# Complex Accessors
+#####################################################################
+
+# These are accessors that have to access the database for additional
+# information about a bug.
+
+# These subs are in alphabetical order, as much as possible.
+# If you add a new sub, please try to keep it in alphabetical order
+# with the other ones.
+
+# Note: If you add a new method, remember that you must check the error
+# state of the bug before returning any data. If $self->{error} is
+# defined, then return something empty. Otherwise you risk potential
+# security holes.
+
+sub dup_id {
+ my ($self) = @_;
+ return $self->{'dup_id'} if exists $self->{'dup_id'};
+
+ $self->{'dup_id'} = undef;
+ return if $self->{'error'};
+
+ if ($self->{'resolution'} eq 'DUPLICATE') {
+ my $dbh = Bugzilla->dbh;
+ $self->{'dup_id'} =
+ $dbh->selectrow_array(q{SELECT dupe_of
+ FROM duplicates
+ WHERE dupe = ?},
+ undef,
+ $self->{'bug_id'});
+ }
+ return $self->{'dup_id'};
+}
+
+sub _resolve_ultimate_dup_id {
+ my ($bug_id, $dupe_of, $loops_are_an_error) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $sth = $dbh->prepare('SELECT dupe_of FROM duplicates WHERE dupe = ?');
+
+ my $this_dup = $dupe_of || $dbh->selectrow_array($sth, undef, $bug_id);
+ my $last_dup = $bug_id;
+
+ my %dupes;
+ while ($this_dup) {
+ if ($this_dup == $bug_id) {
+ if ($loops_are_an_error) {
+ ThrowUserError('dupe_loop_detected', { bug_id => $bug_id,
+ dupe_of => $dupe_of });
+ }
+ else {
+ return $last_dup;
+ }
+ }
+ # If $dupes{$this_dup} is already set to 1, then a loop
+ # already exists which does not involve this bug.
+ # As the user is not responsible for this loop, do not
+ # prevent him from marking this bug as a duplicate.
+ return $last_dup if exists $dupes{$this_dup};
+ $dupes{$this_dup} = 1;
+ $last_dup = $this_dup;
+ $this_dup = $dbh->selectrow_array($sth, undef, $this_dup);
+ }
+
+ return $last_dup;
+}
+
+sub actual_time {
+ my ($self) = @_;
+ return $self->{'actual_time'} if exists $self->{'actual_time'};
+
+ if ( $self->{'error'} || !Bugzilla->user->is_timetracker ) {
+ $self->{'actual_time'} = undef;
+ return $self->{'actual_time'};
+ }
+
+ my $sth = Bugzilla->dbh->prepare("SELECT SUM(work_time)
+ FROM longdescs
+ WHERE longdescs.bug_id=?");
+ $sth->execute($self->{bug_id});
+ $self->{'actual_time'} = $sth->fetchrow_array();
+ return $self->{'actual_time'};
+}
+
+sub any_flags_requesteeble {
+ my ($self) = @_;
+ return $self->{'any_flags_requesteeble'}
+ if exists $self->{'any_flags_requesteeble'};
+ return 0 if $self->{'error'};
+
+ my $any_flags_requesteeble =
+ grep { $_->is_requestable && $_->is_requesteeble } @{$self->flag_types};
+ # Useful in case a flagtype is no longer requestable but a requestee
+ # has been set before we turned off that bit.
+ $any_flags_requesteeble ||= grep { $_->requestee_id } @{$self->flags};
+ $self->{'any_flags_requesteeble'} = $any_flags_requesteeble;
+
+ return $self->{'any_flags_requesteeble'};
+}
+
+sub attachments {
+ my ($self) = @_;
+ return $self->{'attachments'} if exists $self->{'attachments'};
+ return [] if $self->{'error'};
+
+ $self->{'attachments'} =
+ Bugzilla::Attachment->get_attachments_by_bug($self, {preload => 1});
+ return $self->{'attachments'};
+}
+
+sub assigned_to {
+ my ($self) = @_;
+ return $self->{'assigned_to_obj'} if exists $self->{'assigned_to_obj'};
+ $self->{'assigned_to'} = 0 if $self->{'error'};
+ $self->{'assigned_to_obj'} ||= new Bugzilla::User($self->{'assigned_to'});
+ return $self->{'assigned_to_obj'};
+}
+
+sub blocked {
+ my ($self) = @_;
+ return $self->{'blocked'} if exists $self->{'blocked'};
+ return [] if $self->{'error'};
+ $self->{'blocked'} = EmitDependList("dependson", "blocked", $self->bug_id);
+ return $self->{'blocked'};
+}
+
+sub blocks_obj {
+ my ($self) = @_;
+ $self->{blocks_obj} ||= $self->_bugs_in_order($self->blocked);
+ return $self->{blocks_obj};
+}
+
+sub bug_group {
+ my ($self) = @_;
+ return join(', ', (map { $_->name } @{$self->groups_in}));
+}
+
+sub related_bugs {
+ my ($self, $relationship) = @_;
+ return [] if $self->{'error'};
+
+ my $field_name = $relationship->name;
+ $self->{'related_bugs'}->{$field_name} ||= $self->match({$field_name => $self->id});
+ return $self->{'related_bugs'}->{$field_name};
+}
+
+sub cc {
+ my ($self) = @_;
+ return $self->{'cc'} if exists $self->{'cc'};
+ return [] if $self->{'error'};
+
+ my $dbh = Bugzilla->dbh;
+ $self->{'cc'} = $dbh->selectcol_arrayref(
+ q{SELECT profiles.login_name FROM cc, profiles
+ WHERE bug_id = ?
+ AND cc.who = profiles.userid
+ ORDER BY profiles.login_name},
+ undef, $self->bug_id);
+
+ return $self->{'cc'};
+}
+
+# XXX Eventually this will become the standard "cc" method used everywhere.
+sub cc_users {
+ my $self = shift;
+ return $self->{'cc_users'} if exists $self->{'cc_users'};
+ return [] if $self->{'error'};
+
+ my $dbh = Bugzilla->dbh;
+ my $cc_ids = $dbh->selectcol_arrayref(
+ 'SELECT who FROM cc WHERE bug_id = ?', undef, $self->id);
+ $self->{'cc_users'} = Bugzilla::User->new_from_list($cc_ids);
+ return $self->{'cc_users'};
+}
+
+sub component {
+ my ($self) = @_;
+ return $self->{component} if exists $self->{component};
+ return '' if $self->{error};
+ ($self->{component}) = Bugzilla->dbh->selectrow_array(
+ 'SELECT name FROM components WHERE id = ?',
+ undef, $self->{component_id});
+ return $self->{component};
+}
+
+# XXX Eventually this will replace component()
+sub component_obj {
+ my ($self) = @_;
+ return $self->{component_obj} if defined $self->{component_obj};
+ return {} if $self->{error};
+ $self->{component_obj} = new Bugzilla::Component($self->{component_id});
+ return $self->{component_obj};
+}
+
+sub classification_id {
+ my ($self) = @_;
+ return $self->{classification_id} if exists $self->{classification_id};
+ return 0 if $self->{error};
+ ($self->{classification_id}) = Bugzilla->dbh->selectrow_array(
+ 'SELECT classification_id FROM products WHERE id = ?',
+ undef, $self->{product_id});
+ return $self->{classification_id};
+}
+
+sub classification {
+ my ($self) = @_;
+ return $self->{classification} if exists $self->{classification};
+ return '' if $self->{error};
+ ($self->{classification}) = Bugzilla->dbh->selectrow_array(
+ 'SELECT name FROM classifications WHERE id = ?',
+ undef, $self->classification_id);
+ return $self->{classification};
+}
+
+sub default_bug_status {
+ my $class = shift;
+ # XXX This should just call new_bug_statuses when the UI accepts closed
+ # bug statuses instead of accepting them as a parameter.
+ my @statuses = @_;
+
+ my $status;
+ if (scalar(@statuses) == 1) {
+ $status = $statuses[0]->name;
+ }
+ else {
+ $status = ($statuses[0]->name ne 'UNCONFIRMED')
+ ? $statuses[0]->name : $statuses[1]->name;
+ }
+
+ return $status;
+}
+
+sub dependson {
+ my ($self) = @_;
+ return $self->{'dependson'} if exists $self->{'dependson'};
+ return [] if $self->{'error'};
+ $self->{'dependson'} =
+ EmitDependList("blocked", "dependson", $self->bug_id);
+ return $self->{'dependson'};
+}
+
+sub depends_on_obj {
+ my ($self) = @_;
+ $self->{depends_on_obj} ||= $self->_bugs_in_order($self->dependson);
+ return $self->{depends_on_obj};
+}
+
+sub duplicates {
+ my $self = shift;
+ return $self->{duplicates} if exists $self->{duplicates};
+ return [] if $self->{error};
+ $self->{duplicates} = Bugzilla::Bug->new_from_list($self->duplicate_ids);
+ return $self->{duplicates};
+}
+
+sub duplicate_ids {
+ my $self = shift;
+ return $self->{duplicate_ids} if exists $self->{duplicate_ids};
+ return [] if $self->{error};
+
+ my $dbh = Bugzilla->dbh;
+ $self->{duplicate_ids} =
+ $dbh->selectcol_arrayref('SELECT dupe FROM duplicates WHERE dupe_of = ?',
+ undef, $self->id);
+ return $self->{duplicate_ids};
+}
+
+sub flag_types {
+ my ($self) = @_;
+ return $self->{'flag_types'} if exists $self->{'flag_types'};
+ return [] if $self->{'error'};
+
+ my $vars = { target_type => 'bug',
+ product_id => $self->{product_id},
+ component_id => $self->{component_id},
+ bug_id => $self->bug_id };
+
+ $self->{'flag_types'} = Bugzilla::Flag->_flag_types($vars);
+ return $self->{'flag_types'};
+}
+
+sub flags {
+ my $self = shift;
+
+ # Don't cache it as it must be in sync with ->flag_types.
+ $self->{flags} = [map { @{$_->{flags}} } @{$self->flag_types}];
+ return $self->{flags};
+}
+
+sub isopened {
+ my $self = shift;
+ return is_open_state($self->{bug_status}) ? 1 : 0;
+}
+
+sub keywords {
+ my ($self) = @_;
+ return join(', ', (map { $_->name } @{$self->keyword_objects}));
+}
+
+# XXX At some point, this should probably replace the normal "keywords" sub.
+sub keyword_objects {
+ my $self = shift;
+ return $self->{'keyword_objects'} if defined $self->{'keyword_objects'};
+ return [] if $self->{'error'};
+
+ my $dbh = Bugzilla->dbh;
+ my $ids = $dbh->selectcol_arrayref(
+ "SELECT keywordid FROM keywords WHERE bug_id = ?", undef, $self->id);
+ $self->{'keyword_objects'} = Bugzilla::Keyword->new_from_list($ids);
+ return $self->{'keyword_objects'};
+}
+
+sub comments {
+ my ($self, $params) = @_;
+ return [] if $self->{'error'};
+ $params ||= {};
+
+ if (!defined $self->{'comments'}) {
+ $self->{'comments'} = Bugzilla::Comment->match({ bug_id => $self->id });
+ my $count = 0;
+ foreach my $comment (@{ $self->{'comments'} }) {
+ $comment->{count} = $count++;
+ $comment->{bug} = $self;
+ }
+ Bugzilla::Comment->preload($self->{'comments'});
+ }
+ my @comments = @{ $self->{'comments'} };
+
+ my $order = $params->{order}
+ || Bugzilla->user->setting('comment_sort_order');
+ if ($order ne 'oldest_to_newest') {
+ @comments = reverse @comments;
+ if ($order eq 'newest_to_oldest_desc_first') {
+ unshift(@comments, pop @comments);
+ }
+ }
+
+ if ($params->{after}) {
+ my $from = datetime_from($params->{after});
+ @comments = grep { datetime_from($_->creation_ts) > $from } @comments;
+ }
+ if ($params->{to}) {
+ my $to = datetime_from($params->{to});
+ @comments = grep { datetime_from($_->creation_ts) <= $to } @comments;
+ }
+ return \@comments;
+}
+
+sub new_bug_statuses {
+ my ($class, $product) = @_;
+ my $user = Bugzilla->user;
+
+ # Construct the list of allowable statuses.
+ my @statuses = @{ Bugzilla::Bug->statuses_available($product) };
+
+ # If the user has no privs...
+ unless ($user->in_group('editbugs', $product->id)
+ || $user->in_group('canconfirm', $product->id))
+ {
+ # ... use UNCONFIRMED if available, else use the first status of the list.
+ my ($unconfirmed) = grep { $_->name eq 'UNCONFIRMED' } @statuses;
+
+ # Because of an apparent Perl bug, "$unconfirmed || $statuses[0]" doesn't
+ # work, so we're using an "?:" operator. See bug 603314 for details.
+ @statuses = ($unconfirmed ? $unconfirmed : $statuses[0]);
+ }
+
+ return \@statuses;
+}
+
+# This is needed by xt/search.t.
+sub percentage_complete {
+ my $self = shift;
+ return undef if $self->{'error'} || !Bugzilla->user->is_timetracker;
+ my $remaining = $self->remaining_time;
+ my $actual = $self->actual_time;
+ my $total = $remaining + $actual;
+ return undef if $total == 0;
+ # Search.pm truncates this value to an integer, so we want to as well,
+ # since this is mostly used in a test where its value needs to be
+ # identical to what the database will return.
+ return int(100 * ($actual / $total));
+}
+
+sub product {
+ my ($self) = @_;
+ return $self->{product} if exists $self->{product};
+ return '' if $self->{error};
+ ($self->{product}) = Bugzilla->dbh->selectrow_array(
+ 'SELECT name FROM products WHERE id = ?',
+ undef, $self->{product_id});
+ return $self->{product};
+}
+
+# XXX This should eventually replace the "product" subroutine.
+sub product_obj {
+ my $self = shift;
+ return {} if $self->{error};
+ $self->{product_obj} ||= new Bugzilla::Product($self->{product_id});
+ return $self->{product_obj};
+}
+
+sub qa_contact {
+ my ($self) = @_;
+ return $self->{'qa_contact_obj'} if exists $self->{'qa_contact_obj'};
+ return undef if $self->{'error'};
+
+ if (Bugzilla->params->{'useqacontact'} && $self->{'qa_contact'}) {
+ $self->{'qa_contact_obj'} = new Bugzilla::User($self->{'qa_contact'});
+ } else {
+ $self->{'qa_contact_obj'} = undef;
+ }
+ return $self->{'qa_contact_obj'};
+}
+
+sub reporter {
+ my ($self) = @_;
+ return $self->{'reporter'} if exists $self->{'reporter'};
+ $self->{'reporter_id'} = 0 if $self->{'error'};
+ $self->{'reporter'} = new Bugzilla::User($self->{'reporter_id'});
+ return $self->{'reporter'};
+}
+
+sub see_also {
+ my ($self) = @_;
+ return [] if $self->{'error'};
+ if (!exists $self->{see_also}) {
+ my $ids = Bugzilla->dbh->selectcol_arrayref(
+ 'SELECT id FROM bug_see_also WHERE bug_id = ?',
+ undef, $self->id);
+
+ my $bug_urls = Bugzilla::BugUrl->new_from_list($ids);
+
+ $self->{see_also} = $bug_urls;
+ }
+ return $self->{see_also};
+}
+
+sub status {
+ my $self = shift;
+ return undef if $self->{'error'};
+
+ $self->{'status'} ||= new Bugzilla::Status({name => $self->{'bug_status'}});
+ return $self->{'status'};
+}
+
+sub statuses_available {
+ my ($invocant, $product) = @_;
+
+ my @statuses;
+
+ if (ref $invocant) {
+ return [] if $invocant->{'error'};
+
+ return $invocant->{'statuses_available'}
+ if defined $invocant->{'statuses_available'};
+
+ @statuses = @{ $invocant->status->can_change_to };
+ $product = $invocant->product_obj;
+ } else {
+ @statuses = @{ Bugzilla::Status->can_change_to };
+ }
+
+ # UNCONFIRMED is only a valid status if it is enabled in this product.
+ if (!$product->allows_unconfirmed) {
+ @statuses = grep { $_->name ne 'UNCONFIRMED' } @statuses;
+ }
+
+ if (ref $invocant) {
+ my $available = $invocant->_refine_available_statuses(@statuses);
+ $invocant->{'statuses_available'} = $available;
+ return $available;
+ }
+
+ return \@statuses;
+}
+
+sub _refine_available_statuses {
+ my $self = shift;
+ my @statuses = @_;
+
+ my @available;
+ foreach my $status (@statuses) {
+ # Make sure this is a legal status transition
+ next if !$self->check_can_change_field(
+ 'bug_status', $self->status->name, $status->name);
+ push(@available, $status);
+ }
+
+ # If this bug has an inactive status set, it should still be in the list.
+ if (!grep($_->name eq $self->status->name, @available)) {
+ unshift(@available, $self->status);
+ }
+
+ return \@available;
+}
+
+sub show_attachment_flags {
+ my ($self) = @_;
+ return $self->{'show_attachment_flags'}
+ if exists $self->{'show_attachment_flags'};
+ return 0 if $self->{'error'};
+
+ # The number of types of flags that can be set on attachments to this bug
+ # and the number of flags on those attachments. One of these counts must be
+ # greater than zero in order for the "flags" column to appear in the table
+ # of attachments.
+ my $num_attachment_flag_types = Bugzilla::FlagType::count(
+ { 'target_type' => 'attachment',
+ 'product_id' => $self->{'product_id'},
+ 'component_id' => $self->{'component_id'} });
+ my $num_attachment_flags = Bugzilla::Flag->count(
+ { 'target_type' => 'attachment',
+ 'bug_id' => $self->bug_id });
+
+ $self->{'show_attachment_flags'} =
+ ($num_attachment_flag_types || $num_attachment_flags);
+
+ return $self->{'show_attachment_flags'};
+}
+
+sub groups {
+ my $self = shift;
+ return $self->{'groups'} if exists $self->{'groups'};
+ return [] if $self->{'error'};
+
+ my $dbh = Bugzilla->dbh;
+ my @groups;
+
+ # Some of this stuff needs to go into Bugzilla::User
+
+ # For every group, we need to know if there is ANY bug_group_map
+ # record putting the current bug in that group and if there is ANY
+ # user_group_map record putting the user in that group.
+ # The LEFT JOINs are checking for record existence.
+ #
+ my $grouplist = Bugzilla->user->groups_as_string;
+ my $sth = $dbh->prepare(
+ "SELECT DISTINCT groups.id, name, description," .
+ " CASE WHEN bug_group_map.group_id IS NOT NULL" .
+ " THEN 1 ELSE 0 END," .
+ " CASE WHEN groups.id IN($grouplist) THEN 1 ELSE 0 END," .
+ " isactive, membercontrol, othercontrol" .
+ " FROM groups" .
+ " LEFT JOIN bug_group_map" .
+ " ON bug_group_map.group_id = groups.id" .
+ " AND bug_id = ?" .
+ " LEFT JOIN group_control_map" .
+ " ON group_control_map.group_id = groups.id" .
+ " AND group_control_map.product_id = ? " .
+ " WHERE isbuggroup = 1" .
+ " ORDER BY description");
+ $sth->execute($self->{'bug_id'},
+ $self->{'product_id'});
+
+ while (my ($groupid, $name, $description, $ison, $ingroup, $isactive,
+ $membercontrol, $othercontrol) = $sth->fetchrow_array()) {
+
+ $membercontrol ||= 0;
+
+ # For product groups, we only want to use the group if either
+ # (1) The bit is set and not required, or
+ # (2) The group is Shown or Default for members and
+ # the user is a member of the group.
+ if ($ison ||
+ ($isactive && $ingroup
+ && (($membercontrol == CONTROLMAPDEFAULT)
+ || ($membercontrol == CONTROLMAPSHOWN))
+ ))
+ {
+ my $ismandatory = $isactive
+ && ($membercontrol == CONTROLMAPMANDATORY);
+
+ push (@groups, { "bit" => $groupid,
+ "name" => $name,
+ "ison" => $ison,
+ "ingroup" => $ingroup,
+ "mandatory" => $ismandatory,
+ "description" => $description });
+ }
+ }
+
+ $self->{'groups'} = \@groups;
+
+ return $self->{'groups'};
+}
+
+sub groups_in {
+ my $self = shift;
+ return $self->{'groups_in'} if exists $self->{'groups_in'};
+ return [] if $self->{'error'};
+ my $group_ids = Bugzilla->dbh->selectcol_arrayref(
+ 'SELECT group_id FROM bug_group_map WHERE bug_id = ?',
+ undef, $self->id);
+ $self->{'groups_in'} = Bugzilla::Group->new_from_list($group_ids);
+ return $self->{'groups_in'};
+}
+
+sub in_group {
+ my ($self, $group) = @_;
+ return grep($_->id == $group->id, @{$self->groups_in}) ? 1 : 0;
+}
+
+sub user {
+ my $self = shift;
+ return $self->{'user'} if exists $self->{'user'};
+ return {} if $self->{'error'};
+
+ my $user = Bugzilla->user;
+ my $prod_id = $self->{'product_id'};
+
+ my $editbugs = $user->in_group('editbugs', $prod_id);
+ my $is_reporter = $user->id == $self->{reporter_id} ? 1 : 0;
+ my $is_assignee = $user->id == $self->{'assigned_to'} ? 1 : 0;
+ my $is_qa_contact = Bugzilla->params->{'useqacontact'}
+ && $self->{'qa_contact'}
+ && $user->id == $self->{'qa_contact'} ? 1 : 0;
+
+ my $canedit = $editbugs || $is_assignee || $is_qa_contact;
+ my $canconfirm = $editbugs || $user->in_group('canconfirm', $prod_id);
+ my $has_any_role = $is_reporter || $is_assignee || $is_qa_contact;
+
+ $self->{'user'} = {canconfirm => $canconfirm,
+ canedit => $canedit,
+ isreporter => $is_reporter,
+ has_any_role => $has_any_role};
+ return $self->{'user'};
+}
+
+# This is intended to get values that can be selected by the user in the
+# UI. It should not be used for security or validation purposes.
+sub choices {
+ my $self = shift;
+ return $self->{'choices'} if exists $self->{'choices'};
+ return {} if $self->{'error'};
+ my $user = Bugzilla->user;
+
+ my @products = @{ $user->get_enterable_products };
+ # The current product is part of the popup, even if new bugs are no longer
+ # allowed for that product
+ if (!grep($_->name eq $self->product_obj->name, @products)) {
+ unshift(@products, $self->product_obj);
+ }
+ my %class_ids = map { $_->classification_id => 1 } @products;
+ my $classifications =
+ Bugzilla::Classification->new_from_list([keys %class_ids]);
+
+ my %choices = (
+ bug_status => $self->statuses_available,
+ classification => $classifications,
+ product => \@products,
+ component => $self->product_obj->components,
+ version => $self->product_obj->versions,
+ target_milestone => $self->product_obj->milestones,
+ );
+
+ my $resolution_field = new Bugzilla::Field({ name => 'resolution' });
+ # Don't include the empty resolution in drop-downs.
+ my @resolutions = grep($_->name, @{ $resolution_field->legal_values });
+ $choices{'resolution'} = \@resolutions;
+
+ $self->{'choices'} = \%choices;
+ return $self->{'choices'};
+}
+
+# Convenience Function. If you need speed, use this. If you need
+# other Bug fields in addition to this, just create a new Bug with
+# the alias.
+# Queries the database for the bug with a given alias, and returns
+# the ID of the bug if it exists or the undefined value if it doesn't.
+sub bug_alias_to_id {
+ my ($alias) = @_;
+ my $dbh = Bugzilla->dbh;
+ trick_taint($alias);
+ return $dbh->selectrow_array(
+ "SELECT bug_id FROM bugs WHERE alias = ?", undef, $alias);
+}
+
+#####################################################################
+# Subroutines
+#####################################################################
+
+# Returns a list of currently active and editable bug fields,
+# including multi-select fields.
+sub editable_bug_fields {
+ my @fields = Bugzilla->dbh->bz_table_columns('bugs');
+ # Add multi-select fields
+ push(@fields, map { $_->name } @{Bugzilla->fields({obsolete => 0,
+ type => FIELD_TYPE_MULTI_SELECT})});
+ # Obsolete custom fields are not editable.
+ my @obsolete_fields = @{ Bugzilla->fields({obsolete => 1, custom => 1}) };
+ @obsolete_fields = map { $_->name } @obsolete_fields;
+ foreach my $remove ("bug_id", "reporter", "creation_ts", "delta_ts",
+ "lastdiffed", @obsolete_fields)
+ {
+ my $location = firstidx { $_ eq $remove } @fields;
+ # Ensure field exists before attempting to remove it.
+ splice(@fields, $location, 1) if ($location > -1);
+ }
+ # Sorted because the old @::log_columns variable, which this replaces,
+ # was sorted.
+ return sort(@fields);
+}
+
+# XXX - When Bug::update() will be implemented, we should make this routine
+# a private method.
+# Join with bug_status and bugs tables to show bugs with open statuses first,
+# and then the others
+sub EmitDependList {
+ my ($my_field, $target_field, $bug_id, $exclude_resolved) = @_;
+ my $cache = Bugzilla->request_cache->{bug_dependency_list} ||= {};
+
+ my $dbh = Bugzilla->dbh;
+ $exclude_resolved = $exclude_resolved ? 1 : 0;
+ my $is_open_clause = $exclude_resolved ? 'AND is_open = 1' : '';
+
+ $cache->{"${target_field}_sth_$exclude_resolved"} ||= $dbh->prepare(
+ "SELECT $target_field
+ FROM dependencies
+ INNER JOIN bugs ON dependencies.$target_field = bugs.bug_id
+ INNER JOIN bug_status ON bugs.bug_status = bug_status.value
+ WHERE $my_field = ? $is_open_clause
+ ORDER BY is_open DESC, $target_field");
+
+ return $dbh->selectcol_arrayref(
+ $cache->{"${target_field}_sth_$exclude_resolved"},
+ undef, $bug_id);
+}
+
+# Creates a lot of bug objects in the same order as the input array.
+sub _bugs_in_order {
+ my ($self, $bug_ids) = @_;
+ my $bugs = $self->new_from_list($bug_ids);
+ my %bug_map = map { $_->id => $_ } @$bugs;
+ my @result = map { $bug_map{$_} } @$bug_ids;
+ return \@result;
+}
+
+# Get the activity of a bug, starting from $starttime (if given).
+# This routine assumes Bugzilla::Bug->check has been previously called.
+sub get_activity {
+ my ($self, $attach_id, $starttime) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+
+ # Arguments passed to the SQL query.
+ my @args = ($self->id);
+
+ # Only consider changes since $starttime, if given.
+ my $datepart = "";
+ if (defined $starttime) {
+ trick_taint($starttime);
+ push (@args, $starttime);
+ $datepart = "AND bugs_activity.bug_when > ?";
+ }
+
+ my $attachpart = "";
+ if ($attach_id) {
+ push(@args, $attach_id);
+ $attachpart = "AND bugs_activity.attach_id = ?";
+ }
+
+ # Only includes attachments the user is allowed to see.
+ my $suppjoins = "";
+ my $suppwhere = "";
+ if (!$user->is_insider) {
+ $suppjoins = "LEFT JOIN attachments
+ ON attachments.attach_id = bugs_activity.attach_id";
+ $suppwhere = "AND COALESCE(attachments.isprivate, 0) = 0";
+ }
+
+ my $query = "SELECT fielddefs.name, bugs_activity.attach_id, " .
+ $dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s') .
+ ", bugs_activity.removed, bugs_activity.added, profiles.login_name,
+ bugs_activity.comment_id
+ FROM bugs_activity
+ $suppjoins
+ INNER JOIN fielddefs
+ ON bugs_activity.fieldid = fielddefs.id
+ INNER JOIN profiles
+ ON profiles.userid = bugs_activity.who
+ WHERE bugs_activity.bug_id = ?
+ $datepart
+ $attachpart
+ $suppwhere
+ ORDER BY bugs_activity.bug_when, bugs_activity.id";
+
+ my $list = $dbh->selectall_arrayref($query, undef, @args);
+
+ my @operations;
+ my $operation = {};
+ my $changes = [];
+ my $incomplete_data = 0;
+
+ foreach my $entry (@$list) {
+ my ($fieldname, $attachid, $when, $removed, $added, $who, $comment_id) = @$entry;
+ my %change;
+ my $activity_visible = 1;
+
+ # check if the user should see this field's activity
+ if (grep { $fieldname eq $_ } TIMETRACKING_FIELDS) {
+ $activity_visible = $user->is_timetracker;
+ }
+ elsif ($fieldname eq 'longdescs.isprivate'
+ && !$user->is_insider && $added)
+ {
+ $activity_visible = 0;
+ }
+ else {
+ $activity_visible = 1;
+ }
+
+ if ($activity_visible) {
+ # Check for the results of an old Bugzilla data corruption bug
+ if (($added eq '?' && $removed eq '?')
+ || ($added =~ /^\? / || $removed =~ /^\? /)) {
+ $incomplete_data = 1;
+ }
+
+ # An operation, done by 'who' at time 'when', has a number of
+ # 'changes' associated with it.
+ # If this is the start of a new operation, store the data from the
+ # previous one, and set up the new one.
+ if ($operation->{'who'}
+ && ($who ne $operation->{'who'}
+ || $when ne $operation->{'when'}))
+ {
+ $operation->{'changes'} = $changes;
+ push (@operations, $operation);
+
+ # Create new empty anonymous data structures.
+ $operation = {};
+ $changes = [];
+ }
+
+ # If this is the same field as the previous item, then concatenate
+ # the data into the same change.
+ if ($operation->{'who'} && $who eq $operation->{'who'}
+ && $when eq $operation->{'when'}
+ && $fieldname eq $operation->{'fieldname'}
+ && ($comment_id || 0) == ($operation->{'comment_id'} || 0)
+ && ($attachid || 0) == ($operation->{'attachid'} || 0))
+ {
+ my $old_change = pop @$changes;
+ $removed = join_activity_entries($fieldname, $old_change->{'removed'}, $removed);
+ $added = join_activity_entries($fieldname, $old_change->{'added'}, $added);
+ }
+ $operation->{'who'} = $who;
+ $operation->{'when'} = $when;
+ $operation->{'fieldname'} = $change{'fieldname'} = $fieldname;
+ $operation->{'attachid'} = $change{'attachid'} = $attachid;
+ $change{'removed'} = $removed;
+ $change{'added'} = $added;
+
+ if ($comment_id) {
+ $operation->{comment_id} = $change{'comment'} = Bugzilla::Comment->new($comment_id);
+ }
+
+ push (@$changes, \%change);
+ }
+ }
+
+ if ($operation->{'who'}) {
+ $operation->{'changes'} = $changes;
+ push (@operations, $operation);
+ }
+
+ return(\@operations, $incomplete_data);
+}
+
+# Update the bugs_activity table to reflect changes made in bugs.
+sub LogActivityEntry {
+ my ($i, $col, $removed, $added, $whoid, $timestamp, $comment_id) = @_;
+ my $dbh = Bugzilla->dbh;
+ # in the case of CCs, deps, and keywords, there's a possibility that someone
+ # might try to add or remove a lot of them at once, which might take more
+ # space than the activity table allows. We'll solve this by splitting it
+ # into multiple entries if it's too long.
+ while ($removed || $added) {
+ my ($removestr, $addstr) = ($removed, $added);
+ if (length($removestr) > MAX_LINE_LENGTH) {
+ my $commaposition = find_wrap_point($removed, MAX_LINE_LENGTH);
+ $removestr = substr($removed, 0, $commaposition);
+ $removed = substr($removed, $commaposition);
+ } else {
+ $removed = ""; # no more entries
+ }
+ if (length($addstr) > MAX_LINE_LENGTH) {
+ my $commaposition = find_wrap_point($added, MAX_LINE_LENGTH);
+ $addstr = substr($added, 0, $commaposition);
+ $added = substr($added, $commaposition);
+ } else {
+ $added = ""; # no more entries
+ }
+ trick_taint($addstr);
+ trick_taint($removestr);
+ my $fieldid = get_field_id($col);
+ $dbh->do("INSERT INTO bugs_activity
+ (bug_id, who, bug_when, fieldid, removed, added, comment_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)",
+ undef, ($i, $whoid, $timestamp, $fieldid, $removestr, $addstr, $comment_id));
+ }
+}
+
+# Convert WebService API and email_in.pl field names to internal DB field
+# names.
+sub map_fields {
+ my ($params, $except) = @_;
+
+ my %field_values;
+ foreach my $field (keys %$params) {
+ # Don't allow setting private fields via email_in or the WebService.
+ next if $field =~ /^_/;
+ my $field_name;
+ if ($except->{$field}) {
+ $field_name = $field;
+ }
+ else {
+ $field_name = FIELD_MAP->{$field} || $field;
+ }
+ $field_values{$field_name} = $params->{$field};
+ }
+ return \%field_values;
+}
+
+################################################################################
+# check_can_change_field() defines what users are allowed to change. You
+# can add code here for site-specific policy changes, according to the
+# instructions given in the Bugzilla Guide and below. Note that you may also
+# have to update the Bugzilla::Bug::user() function to give people access to the
+# options that they are permitted to change.
+#
+# check_can_change_field() returns true if the user is allowed to change this
+# field, and false if they are not.
+#
+# The parameters to this method are as follows:
+# $field - name of the field in the bugs table the user is trying to change
+# $oldvalue - what they are changing it from
+# $newvalue - what they are changing it to
+# $PrivilegesRequired - return the reason of the failure, if any
+################################################################################
+sub check_can_change_field {
+ my $self = shift;
+ my ($field, $oldvalue, $newvalue, $PrivilegesRequired) = (@_);
+ my $user = Bugzilla->user;
+
+ $oldvalue = defined($oldvalue) ? $oldvalue : '';
+ $newvalue = defined($newvalue) ? $newvalue : '';
+
+ # Return true if they haven't changed this field at all.
+ if ($oldvalue eq $newvalue) {
+ return 1;
+ } elsif (ref($newvalue) eq 'ARRAY' && ref($oldvalue) eq 'ARRAY') {
+ my ($removed, $added) = diff_arrays($oldvalue, $newvalue);
+ return 1 if !scalar(@$removed) && !scalar(@$added);
+ } elsif (trim($oldvalue) eq trim($newvalue)) {
+ return 1;
+ # numeric fields need to be compared using ==
+ } elsif (($field eq 'estimated_time' || $field eq 'remaining_time'
+ || $field eq 'work_time')
+ && $oldvalue == $newvalue)
+ {
+ return 1;
+ }
+
+ my @priv_results;
+ Bugzilla::Hook::process('bug_check_can_change_field',
+ { bug => $self, field => $field,
+ new_value => $newvalue, old_value => $oldvalue,
+ priv_results => \@priv_results });
+ if (my $priv_required = first { $_ > 0 } @priv_results) {
+ $$PrivilegesRequired = $priv_required;
+ return 0;
+ }
+ my $allow_found = first { $_ == 0 } @priv_results;
+ if (defined $allow_found) {
+ return 1;
+ }
+
+ # Allow anyone to change comments, or set flags
+ if ($field =~ /^longdesc/ || $field eq 'flagtypes.name') {
+ return 1;
+ }
+
+ # If the user isn't allowed to change a field, we must tell him who can.
+ # We store the required permission set into the $PrivilegesRequired
+ # variable which gets passed to the error template.
+ #
+ # $PrivilegesRequired = PRIVILEGES_REQUIRED_NONE : no privileges required;
+ # $PrivilegesRequired = PRIVILEGES_REQUIRED_REPORTER : the reporter, assignee or an empowered user;
+ # $PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE : the assignee or an empowered user;
+ # $PrivilegesRequired = PRIVILEGES_REQUIRED_EMPOWERED : an empowered user.
+
+ # Only users in the time-tracking group can change time-tracking fields.
+ if ( grep($_ eq $field, TIMETRACKING_FIELDS) ) {
+ if (!$user->is_timetracker) {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_EMPOWERED;
+ return 0;
+ }
+ }
+
+ # Allow anyone with (product-specific) "editbugs" privs to change anything.
+ if ($user->in_group('editbugs', $self->{'product_id'})) {
+ return 1;
+ }
+
+ # *Only* users with (product-specific) "canconfirm" privs can confirm bugs.
+ if ($self->_changes_everconfirmed($field, $oldvalue, $newvalue)) {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_EMPOWERED;
+ return $user->in_group('canconfirm', $self->{'product_id'});
+ }
+
+ # Make sure that a valid bug ID has been given.
+ if (!$self->{'error'}) {
+ # Allow the assignee to change anything else.
+ if ($self->{'assigned_to'} == $user->id
+ || $self->{'_old_assigned_to'} && $self->{'_old_assigned_to'} == $user->id)
+ {
+ return 1;
+ }
+
+ # Allow the QA contact to change anything else.
+ if (Bugzilla->params->{'useqacontact'}
+ && (($self->{'qa_contact'} && $self->{'qa_contact'} == $user->id)
+ || ($self->{'_old_qa_contact'} && $self->{'_old_qa_contact'} == $user->id)))
+ {
+ return 1;
+ }
+ }
+
+ # At this point, the user is either the reporter or an
+ # unprivileged user. We first check for fields the reporter
+ # is not allowed to change.
+
+ # The reporter may not:
+ # - reassign bugs, unless the bugs are assigned to him;
+ # in that case we will have already returned 1 above
+ # when checking for the assignee of the bug.
+ if ($field eq 'assigned_to') {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE;
+ return 0;
+ }
+ # - change the QA contact
+ if ($field eq 'qa_contact') {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE;
+ return 0;
+ }
+ # - change the target milestone
+ if ($field eq 'target_milestone') {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE;
+ return 0;
+ }
+ # - change the priority (unless he could have set it originally)
+ if ($field eq 'priority'
+ && !Bugzilla->params->{'letsubmitterchoosepriority'})
+ {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE;
+ return 0;
+ }
+ # - unconfirm bugs (confirming them is handled above)
+ if ($field eq 'everconfirmed') {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE;
+ return 0;
+ }
+ # - change the status from one open state to another
+ if ($field eq 'bug_status'
+ && is_open_state($oldvalue) && is_open_state($newvalue))
+ {
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE;
+ return 0;
+ }
+
+ # The reporter is allowed to change anything else.
+ if (!$self->{'error'} && $self->{'reporter_id'} == $user->id) {
+ return 1;
+ }
+
+ # If we haven't returned by this point, then the user doesn't
+ # have the necessary permissions to change this field.
+ $$PrivilegesRequired = PRIVILEGES_REQUIRED_REPORTER;
+ return 0;
+}
+
+# A helper for check_can_change_field
+sub _changes_everconfirmed {
+ my ($self, $field, $old, $new) = @_;
+ return 1 if $field eq 'everconfirmed';
+ if ($field eq 'bug_status') {
+ if ($self->everconfirmed) {
+ # Moving a confirmed bug to UNCONFIRMED will change everconfirmed.
+ return 1 if $new eq 'UNCONFIRMED';
+ }
+ else {
+ # Moving an unconfirmed bug to an open state that isn't
+ # UNCONFIRMED will confirm the bug.
+ return 1 if (is_open_state($new) and $new ne 'UNCONFIRMED');
+ }
+ }
+ return 0;
+}
+
+#
+# Field Validation
+#
+
+# Validate and return a hash of dependencies
+sub ValidateDependencies {
+ my $fields = {};
+ # These can be arrayrefs or they can be strings.
+ $fields->{'dependson'} = shift;
+ $fields->{'blocked'} = shift;
+ my $id = shift || 0;
+
+ unless (defined($fields->{'dependson'})
+ || defined($fields->{'blocked'}))
+ {
+ return;
+ }
+
+ my $dbh = Bugzilla->dbh;
+ my %deps;
+ my %deptree;
+ foreach my $pair (["blocked", "dependson"], ["dependson", "blocked"]) {
+ my ($me, $target) = @{$pair};
+ $deptree{$target} = [];
+ $deps{$target} = [];
+ next unless $fields->{$target};
+
+ my %seen;
+ my $target_array = ref($fields->{$target}) ? $fields->{$target}
+ : [split(/[\s,]+/, $fields->{$target})];
+ foreach my $i (@$target_array) {
+ if ($id == $i) {
+ ThrowUserError("dependency_loop_single");
+ }
+ if (!exists $seen{$i}) {
+ push(@{$deptree{$target}}, $i);
+ $seen{$i} = 1;
+ }
+ }
+ # populate $deps{$target} as first-level deps only.
+ # and find remainder of dependency tree in $deptree{$target}
+ @{$deps{$target}} = @{$deptree{$target}};
+ my @stack = @{$deps{$target}};
+ while (@stack) {
+ my $i = shift @stack;
+ my $dep_list =
+ $dbh->selectcol_arrayref("SELECT $target
+ FROM dependencies
+ WHERE $me = ?", undef, $i);
+ foreach my $t (@$dep_list) {
+ # ignore any _current_ dependencies involving this bug,
+ # as they will be overwritten with data from the form.
+ if ($t != $id && !exists $seen{$t}) {
+ push(@{$deptree{$target}}, $t);
+ push @stack, $t;
+ $seen{$t} = 1;
+ }
+ }
+ }
+ }
+
+ my @deps = @{$deptree{'dependson'}};
+ my @blocks = @{$deptree{'blocked'}};
+ my %union = ();
+ my %isect = ();
+ foreach my $b (@deps, @blocks) { $union{$b}++ && $isect{$b}++ }
+ my @isect = keys %isect;
+ if (scalar(@isect) > 0) {
+ ThrowUserError("dependency_loop_multi", {'deps' => \@isect});
+ }
+ return %deps;
+}
+
+
+#####################################################################
+# Custom Field Accessors
+#####################################################################
+
+sub _create_cf_accessors {
+ my ($invocant) = @_;
+ my $class = ref($invocant) || $invocant;
+ return if Bugzilla->request_cache->{"${class}_cf_accessors_created"};
+
+ my $fields = Bugzilla->fields({ custom => 1 });
+ foreach my $field (@$fields) {
+ my $accessor = $class->_accessor_for($field);
+ my $name = "${class}::" . $field->name;
+ {
+ no strict 'refs';
+ next if defined *{$name};
+ *{$name} = $accessor;
+ }
+ }
+
+ Bugzilla->request_cache->{"${class}_cf_accessors_created"} = 1;
+}
+
+sub _accessor_for {
+ my ($class, $field) = @_;
+ if ($field->type == FIELD_TYPE_MULTI_SELECT) {
+ return $class->_multi_select_accessor($field->name);
+ }
+ return $class->_cf_accessor($field->name);
+}
+
+sub _cf_accessor {
+ my ($class, $field) = @_;
+ my $accessor = sub {
+ my ($self) = @_;
+ return $self->{$field};
+ };
+ return $accessor;
+}
+
+sub _multi_select_accessor {
+ my ($class, $field) = @_;
+ my $accessor = sub {
+ my ($self) = @_;
+ $self->{$field} ||= Bugzilla->dbh->selectcol_arrayref(
+ "SELECT value FROM bug_$field WHERE bug_id = ? ORDER BY value",
+ undef, $self->id);
+ return $self->{$field};
+ };
+ return $accessor;
+}
+
+1;
diff --git a/Bugzilla/BugMail.pm b/Bugzilla/BugMail.pm
new file mode 100644
index 0000000..5a2c9b7
--- /dev/null
+++ b/Bugzilla/BugMail.pm
@@ -0,0 +1,508 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+
+package Bugzilla::BugMail;
+
+use Bugzilla::Error;
+use Bugzilla::User;
+use Bugzilla::Constants;
+use Bugzilla::Util;
+use Bugzilla::Bug;
+use Bugzilla::Comment;
+use Bugzilla::Mailer;
+use Bugzilla::Hook;
+
+use Date::Parse;
+use Date::Format;
+use Scalar::Util qw(blessed);
+use List::MoreUtils qw(uniq);
+
+use constant BIT_DIRECT => 1;
+use constant BIT_WATCHING => 2;
+
+sub relationships {
+ my $ref = RELATIONSHIPS;
+ # Clone it so that we don't modify the constant;
+ my %relationships = %$ref;
+ Bugzilla::Hook::process('bugmail_relationships',
+ { relationships => \%relationships });
+ return %relationships;
+}
+
+# This is a bit of a hack, basically keeping the old system()
+# cmd line interface. Should clean this up at some point.
+#
+# args: bug_id, and an optional hash ref which may have keys for:
+# changer, owner, qa, reporter, cc
+# Optional hash contains values of people which will be forced to those
+# roles when the email is sent.
+# All the names are email addresses, not userids
+# values are scalars, except for cc, which is a list
+sub Send {
+ my ($id, $forced, $params) = @_;
+ $params ||= {};
+
+ my $dbh = Bugzilla->dbh;
+ my $bug = new Bugzilla::Bug($id);
+
+ my $start = $bug->lastdiffed;
+ my $end = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
+
+ # Bugzilla::User objects of people in various roles. More than one person
+ # can 'have' a role, if the person in that role has changed, or people are
+ # watching.
+ my @assignees = ($bug->assigned_to);
+ my @qa_contacts = $bug->qa_contact || ();
+
+ my @ccs = @{ $bug->cc_users };
+ # Include the people passed in as being in particular roles.
+ # This can include people who used to hold those roles.
+ # At this point, we don't care if there are duplicates in these arrays.
+ my $changer = $forced->{'changer'};
+ if ($forced->{'owner'}) {
+ push (@assignees, Bugzilla::User->check($forced->{'owner'}));
+ }
+
+ if ($forced->{'qacontact'}) {
+ push (@qa_contacts, Bugzilla::User->check($forced->{'qacontact'}));
+ }
+
+ if ($forced->{'cc'}) {
+ foreach my $cc (@{$forced->{'cc'}}) {
+ push(@ccs, Bugzilla::User->check($cc));
+ }
+ }
+ my %user_cache = map { $_->id => $_ } (@assignees, @qa_contacts, @ccs);
+
+ my @diffs;
+ if (!$start) {
+ @diffs = _get_new_bugmail_fields($bug);
+ }
+
+ if ($params->{dep_only}) {
+ push(@diffs, { field_name => 'bug_status',
+ old => $params->{changes}->{bug_status}->[0],
+ new => $params->{changes}->{bug_status}->[1],
+ login_name => $changer->login,
+ who => $changer,
+ blocker => $params->{blocker} },
+ { field_name => 'resolution',
+ old => $params->{changes}->{resolution}->[0],
+ new => $params->{changes}->{resolution}->[1],
+ login_name => $changer->login,
+ who => $changer,
+ blocker => $params->{blocker} });
+ }
+ else {
+ push(@diffs, _get_diffs($bug, $end, \%user_cache));
+ }
+
+ my $comments = $bug->comments({ after => $start, to => $end });
+ # Skip empty comments.
+ @$comments = grep { $_->type || $_->body =~ /\S/ } @$comments;
+
+ ###########################################################################
+ # Start of email filtering code
+ ###########################################################################
+
+ # A user_id => roles hash to keep track of people.
+ my %recipients;
+ my %watching;
+
+ # Now we work out all the people involved with this bug, and note all of
+ # the relationships in a hash. The keys are userids, the values are an
+ # array of role constants.
+
+ # CCs
+ $recipients{$_->id}->{+REL_CC} = BIT_DIRECT foreach (@ccs);
+
+ # Reporter (there's only ever one)
+ $recipients{$bug->reporter->id}->{+REL_REPORTER} = BIT_DIRECT;
+
+ # QA Contact
+ if (Bugzilla->params->{'useqacontact'}) {
+ foreach (@qa_contacts) {
+ # QA Contact can be blank; ignore it if so.
+ $recipients{$_->id}->{+REL_QA} = BIT_DIRECT if $_;
+ }
+ }
+
+ # Assignee
+ $recipients{$_->id}->{+REL_ASSIGNEE} = BIT_DIRECT foreach (@assignees);
+
+ # The last relevant set of people are those who are being removed from
+ # their roles in this change. We get their names out of the diffs.
+ foreach my $change (@diffs) {
+ if ($change->{old}) {
+ # You can't stop being the reporter, so we don't check that
+ # relationship here.
+ # Ignore people whose user account has been deleted or renamed.
+ if ($change->{field_name} eq 'cc') {
+ foreach my $cc_user (split(/[\s,]+/, $change->{old})) {
+ my $uid = login_to_id($cc_user);
+ $recipients{$uid}->{+REL_CC} = BIT_DIRECT if $uid;
+ }
+ }
+ elsif ($change->{field_name} eq 'qa_contact') {
+ my $uid = login_to_id($change->{old});
+ $recipients{$uid}->{+REL_QA} = BIT_DIRECT if $uid;
+ }
+ elsif ($change->{field_name} eq 'assigned_to') {
+ my $uid = login_to_id($change->{old});
+ $recipients{$uid}->{+REL_ASSIGNEE} = BIT_DIRECT if $uid;
+ }
+ }
+ }
+
+ # Make sure %user_cache has every user in it so far referenced
+ foreach my $user_id (keys %recipients) {
+ $user_cache{$user_id} ||= new Bugzilla::User($user_id);
+ }
+
+ Bugzilla::Hook::process('bugmail_recipients',
+ { bug => $bug, recipients => \%recipients,
+ users => \%user_cache, diffs => \@diffs });
+
+ # Find all those user-watching anyone on the current list, who is not
+ # on it already themselves.
+ my $involved = join(",", keys %recipients);
+
+ my $userwatchers =
+ $dbh->selectall_arrayref("SELECT watcher, watched FROM watch
+ WHERE watched IN ($involved)");
+
+ # Mark these people as having the role of the person they are watching
+ foreach my $watch (@$userwatchers) {
+ while (my ($role, $bits) = each %{$recipients{$watch->[1]}}) {
+ $recipients{$watch->[0]}->{$role} |= BIT_WATCHING
+ if $bits & BIT_DIRECT;
+ }
+ push(@{$watching{$watch->[0]}}, $watch->[1]);
+ }
+
+ # Global watcher
+ my @watchers = split(/[,\s]+/, Bugzilla->params->{'globalwatchers'});
+ foreach (@watchers) {
+ my $watcher_id = login_to_id($_);
+ next unless $watcher_id;
+ $recipients{$watcher_id}->{+REL_GLOBAL_WATCHER} = BIT_DIRECT;
+ }
+
+ # We now have a complete set of all the users, and their relationships to
+ # the bug in question. However, we are not necessarily going to mail them
+ # all - there are preferences, permissions checks and all sorts to do yet.
+ my @sent;
+ my @excluded;
+
+ # The email client will display the Date: header in the desired timezone,
+ # so we can always use UTC here.
+ my $date = $params->{dep_only} ? $end : $bug->delta_ts;
+ $date = format_time($date, '%a, %d %b %Y %T %z', 'UTC');
+
+ foreach my $user_id (keys %recipients) {
+ my %rels_which_want;
+ my $sent_mail = 0;
+ $user_cache{$user_id} ||= new Bugzilla::User($user_id);
+ my $user = $user_cache{$user_id};
+ # Deleted users must be excluded.
+ next unless $user;
+
+ if ($user->can_see_bug($id)) {
+ # Go through each role the user has and see if they want mail in
+ # that role.
+ foreach my $relationship (keys %{$recipients{$user_id}}) {
+ if ($user->wants_bug_mail($bug,
+ $relationship,
+ $start ? \@diffs : [],
+ $comments,
+ $params->{dep_only},
+ $changer))
+ {
+ $rels_which_want{$relationship} =
+ $recipients{$user_id}->{$relationship};
+ }
+ }
+ }
+
+ if (scalar(%rels_which_want)) {
+ # So the user exists, can see the bug, and wants mail in at least
+ # one role. But do we want to send it to them?
+
+ # We shouldn't send mail if this is a dependency mail and the
+ # depending bug is not visible to the user.
+ # This is to avoid leaking the summary of a confidential bug.
+ my $dep_ok = 1;
+ if ($params->{dep_only}) {
+ $dep_ok = $user->can_see_bug($params->{blocker}->id) ? 1 : 0;
+ }
+
+ # Make sure the user isn't in the nomail list, and the dep check passed.
+ if ($user->email_enabled && $dep_ok) {
+ # OK, OK, if we must. Email the user.
+ $sent_mail = sendMail(
+ { to => $user,
+ bug => $bug,
+ comments => $comments,
+ date => $date,
+ changer => $changer,
+ watchers => exists $watching{$user_id} ?
+ $watching{$user_id} : undef,
+ diffs => \@diffs,
+ rels_which_want => \%rels_which_want,
+ });
+ }
+ }
+
+ if ($sent_mail) {
+ push(@sent, $user->login);
+ }
+ else {
+ push(@excluded, $user->login);
+ }
+ }
+
+ # When sending bugmail about a blocker being reopened or resolved,
+ # we say nothing about changes in the bug being blocked, so we must
+ # not update lastdiffed in this case.
+ if (!$params->{dep_only}) {
+ $dbh->do('UPDATE bugs SET lastdiffed = ? WHERE bug_id = ?',
+ undef, ($end, $id));
+ $bug->{lastdiffed} = $end;
+ }
+
+ return {'sent' => \@sent, 'excluded' => \@excluded};
+}
+
+sub sendMail {
+ my $params = shift;
+
+ my $user = $params->{to};
+ my $bug = $params->{bug};
+ my @send_comments = @{ $params->{comments} };
+ my $date = $params->{date};
+ my $changer = $params->{changer};
+ my $watchingRef = $params->{watchers};
+ my @diffs = @{ $params->{diffs} };
+ my $relRef = $params->{rels_which_want};
+
+ # Only display changes the user is allowed see.
+ my @display_diffs;
+
+ foreach my $diff (@diffs) {
+ my $add_diff = 0;
+
+ if (grep { $_ eq $diff->{field_name} } TIMETRACKING_FIELDS) {
+ $add_diff = 1 if $user->is_timetracker;
+ }
+ elsif (!$diff->{isprivate} || $user->is_insider) {
+ $add_diff = 1;
+ }
+ push(@display_diffs, $diff) if $add_diff;
+ }
+
+ if (!$user->is_insider) {
+ @send_comments = grep { !$_->is_private } @send_comments;
+ }
+
+ if (!scalar(@display_diffs) && !scalar(@send_comments)) {
+ # Whoops, no differences!
+ return 0;
+ }
+
+ my (@reasons, @reasons_watch);
+ while (my ($relationship, $bits) = each %{$relRef}) {
+ push(@reasons, $relationship) if ($bits & BIT_DIRECT);
+ push(@reasons_watch, $relationship) if ($bits & BIT_WATCHING);
+ }
+
+ my %relationships = relationships();
+ my @headerrel = map { $relationships{$_} } @reasons;
+ my @watchingrel = map { $relationships{$_} } @reasons_watch;
+ push(@headerrel, 'None') unless @headerrel;
+ push(@watchingrel, 'None') unless @watchingrel;
+ push @watchingrel, map { user_id_to_login($_) } @$watchingRef;
+
+ my @changedfields = uniq map { $_->{field_name} } @display_diffs;
+
+ # Add attachments.created to changedfields if one or more
+ # comments contain information about a new attachment
+ if (grep($_->type == CMT_ATTACHMENT_CREATED, @send_comments)) {
+ push(@changedfields, 'attachments.created');
+ }
+
+ my $vars = {
+ date => $date,
+ to_user => $user,
+ bug => $bug,
+ reasons => \@reasons,
+ reasons_watch => \@reasons_watch,
+ reasonsheader => join(" ", @headerrel),
+ reasonswatchheader => join(" ", @watchingrel),
+ changer => $changer,
+ diffs => \@display_diffs,
+ changedfields => \@changedfields,
+ new_comments => \@send_comments,
+ threadingmarker => build_thread_marker($bug->id, $user->id, !$bug->lastdiffed),
+ };
+ my $msg = _generate_bugmail($user, $vars);
+ MessageToMTA($msg);
+
+ return 1;
+}
+
+sub _generate_bugmail {
+ my ($user, $vars) = @_;
+ my $template = Bugzilla->template_inner($user->setting('lang'));
+ my ($msg_text, $msg_html, $msg_header);
+
+ $template->process("email/bugmail-header.txt.tmpl", $vars, \$msg_header)
+ || ThrowTemplateError($template->error());
+ $template->process("email/bugmail.txt.tmpl", $vars, \$msg_text)
+ || ThrowTemplateError($template->error());
+
+ my @parts = (
+ Email::MIME->create(
+ attributes => {
+ content_type => "text/plain",
+ },
+ body => $msg_text,
+ )
+ );
+ if ($user->setting('email_format') eq 'html') {
+ $template->process("email/bugmail.html.tmpl", $vars, \$msg_html)
+ || ThrowTemplateError($template->error());
+ push @parts, Email::MIME->create(
+ attributes => {
+ content_type => "text/html",
+ },
+ body => $msg_html,
+ );
+ }
+
+ # TT trims the trailing newline, and threadingmarker may be ignored.
+ my $email = new Email::MIME("$msg_header\n");
+ if (scalar(@parts) == 1) {
+ $email->content_type_set($parts[0]->content_type);
+ } else {
+ $email->content_type_set('multipart/alternative');
+ # Some mail clients need same encoding for each part, even empty ones.
+ $email->charset_set('UTF-8') if Bugzilla->params->{'utf8'};
+ }
+ $email->parts_set(\@parts);
+ return $email;
+}
+
+sub _get_diffs {
+ my ($bug, $end, $user_cache) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my @args = ($bug->id);
+ # If lastdiffed is NULL, then we don't limit the search on time.
+ my $when_restriction = '';
+ if ($bug->lastdiffed) {
+ $when_restriction = ' AND bug_when > ? AND bug_when <= ?';
+ push @args, ($bug->lastdiffed, $end);
+ }
+
+ my $diffs = $dbh->selectall_arrayref(
+ "SELECT fielddefs.name AS field_name,
+ bugs_activity.bug_when, bugs_activity.removed AS old,
+ bugs_activity.added AS new, bugs_activity.attach_id,
+ bugs_activity.comment_id, bugs_activity.who
+ FROM bugs_activity
+ INNER JOIN fielddefs
+ ON fielddefs.id = bugs_activity.fieldid
+ WHERE bugs_activity.bug_id = ?
+ $when_restriction
+ ORDER BY bugs_activity.bug_when, bugs_activity.id",
+ {Slice=>{}}, @args);
+
+ foreach my $diff (@$diffs) {
+ $user_cache->{$diff->{who}} ||= new Bugzilla::User($diff->{who});
+ $diff->{who} = $user_cache->{$diff->{who}};
+ if ($diff->{attach_id}) {
+ $diff->{isprivate} = $dbh->selectrow_array(
+ 'SELECT isprivate FROM attachments WHERE attach_id = ?',
+ undef, $diff->{attach_id});
+ }
+ if ($diff->{field_name} eq 'longdescs.isprivate') {
+ my $comment = Bugzilla::Comment->new($diff->{comment_id});
+ $diff->{num} = $comment->count;
+ $diff->{isprivate} = $diff->{new};
+ }
+ }
+
+ my @changes = ();
+ foreach my $diff (@$diffs) {
+ # If this is the same field as the previous item, then concatenate
+ # the data into the same change.
+ if (scalar(@changes)
+ && $diff->{field_name} eq $changes[-1]->{field_name}
+ && $diff->{bug_when} eq $changes[-1]->{bug_when}
+ && $diff->{who} eq $changes[-1]->{who}
+ && ($diff->{attach_id} || 0) == ($changes[-1]->{attach_id} || 0)
+ && ($diff->{comment_id} || 0) == ($changes[-1]->{comment_id} || 0)
+ ) {
+ my $old_change = pop @changes;
+ $diff->{old} = join_activity_entries($diff->{field_name}, $old_change->{old}, $diff->{old});
+ $diff->{new} = join_activity_entries($diff->{field_name}, $old_change->{new}, $diff->{new});
+ }
+ push @changes, $diff;
+ }
+
+ return @changes;
+}
+
+sub _get_new_bugmail_fields {
+ my $bug = shift;
+ my @fields = @{ Bugzilla->fields({obsolete => 0, in_new_bugmail => 1}) };
+ my @diffs;
+ my $params = Bugzilla->params;
+
+ foreach my $field (@fields) {
+ my $name = $field->name;
+ my $value = $bug->$name;
+
+ next if !$field->is_visible_on_bug($bug)
+ || ($name eq 'classification' && !$params->{'useclassification'})
+ || ($name eq 'status_whiteboard' && !$params->{'usestatuswhiteboard'})
+ || ($name eq 'qa_contact' && !$params->{'useqacontact'})
+ || ($name eq 'target_milestone' && !$params->{'usetargetmilestone'});
+
+ if (ref $value eq 'ARRAY') {
+ $value = join(', ', @$value);
+ }
+ elsif (blessed($value) && $value->isa('Bugzilla::User')) {
+ $value = $value->login;
+ }
+ elsif (blessed($value) && $value->isa('Bugzilla::Object')) {
+ $value = $value->name;
+ }
+ elsif ($name eq 'estimated_time') {
+ # "0.00" (which is what we get from the DB) is true,
+ # so we explicitly do a numerical comparison with 0.
+ $value = 0 if $value == 0;
+ }
+ elsif ($name eq 'deadline') {
+ $value = time2str("%Y-%m-%d", str2time($value)) if $value;
+ }
+
+ # If there isn't anything to show, don't include this header.
+ next unless $value;
+
+ push(@diffs, {
+ field_name => $name,
+ who => $bug->reporter,
+ new => $value});
+ }
+
+ return @diffs;
+}
+
+1;
diff --git a/Bugzilla/BugUrl.pm b/Bugzilla/BugUrl.pm
new file mode 100644
index 0000000..8689e8a
--- /dev/null
+++ b/Bugzilla/BugUrl.pm
@@ -0,0 +1,200 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl;
+use strict;
+use base qw(Bugzilla::Object);
+
+use Bugzilla::Util;
+use Bugzilla::Error;
+use Bugzilla::Constants;
+use Bugzilla::Hook;
+
+use URI::QueryParam;
+
+###############################
+#### Initialization ####
+###############################
+
+use constant DB_TABLE => 'bug_see_also';
+use constant NAME_FIELD => 'value';
+use constant LIST_ORDER => 'id';
+# See Also is tracked in bugs_activity.
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
+use constant AUDIT_REMOVES => 0;
+
+use constant DB_COLUMNS => qw(
+ id
+ bug_id
+ value
+ class
+);
+
+# This must be strings with the names of the validations,
+# instead of coderefs, because subclasses override these
+# validators with their own.
+use constant VALIDATORS => {
+ value => '_check_value',
+ bug_id => '_check_bug_id',
+ class => \&_check_class,
+};
+
+# This is the order we go through all of subclasses and
+# pick the first one that should handle the url. New
+# subclasses should be added at the end of the list.
+use constant SUB_CLASSES => qw(
+ Bugzilla::BugUrl::Bugzilla::Local
+ Bugzilla::BugUrl::Bugzilla
+ Bugzilla::BugUrl::Launchpad
+ Bugzilla::BugUrl::Google
+ Bugzilla::BugUrl::Debian
+ Bugzilla::BugUrl::JIRA
+ Bugzilla::BugUrl::Trac
+ Bugzilla::BugUrl::MantisBT
+ Bugzilla::BugUrl::SourceForge
+ Bugzilla::BugUrl::GitHub
+);
+
+###############################
+#### Accessors ######
+###############################
+
+sub class { return $_[0]->{class} }
+sub bug_id { return $_[0]->{bug_id} }
+
+###############################
+#### Methods ####
+###############################
+
+sub new {
+ my $class = shift;
+ my $param = shift;
+
+ if (ref $param) {
+ my $bug_id = $param->{bug_id};
+ my $name = $param->{name} || $param->{value};
+ if (!defined $bug_id) {
+ ThrowCodeError('bad_arg',
+ { argument => 'bug_id',
+ function => "${class}::new" });
+ }
+ if (!defined $name) {
+ ThrowCodeError('bad_arg',
+ { argument => 'name',
+ function => "${class}::new" });
+ }
+
+ my $condition = 'bug_id = ? AND value = ?';
+ my @values = ($bug_id, $name);
+ $param = { condition => $condition, values => \@values };
+ }
+
+ unshift @_, $param;
+ return $class->SUPER::new(@_);
+}
+
+sub _do_list_select {
+ my $class = shift;
+ my $objects = $class->SUPER::_do_list_select(@_);
+
+ foreach my $object (@$objects) {
+ eval "use " . $object->class; die $@ if $@;
+ bless $object, $object->class;
+ }
+
+ return $objects
+}
+
+# This is an abstract method. It must be overridden
+# in every subclass.
+sub should_handle {
+ my ($class, $input) = @_;
+ ThrowCodeError('unknown_method',
+ { method => "${class}::should_handle" });
+}
+
+sub class_for {
+ my ($class, $value) = @_;
+
+ my @sub_classes = $class->SUB_CLASSES;
+ Bugzilla::Hook::process("bug_url_sub_classes",
+ { sub_classes => \@sub_classes });
+
+ my $uri = URI->new($value);
+ foreach my $subclass (@sub_classes) {
+ eval "use $subclass";
+ die $@ if $@;
+ return wantarray ? ($subclass, $uri) : $subclass
+ if $subclass->should_handle($uri);
+ }
+
+ ThrowUserError('bug_url_invalid', { url => $value });
+}
+
+sub _check_class {
+ my ($class, $subclass) = @_;
+ eval "use $subclass"; die $@ if $@;
+ return $subclass;
+}
+
+sub _check_bug_id {
+ my ($class, $bug_id) = @_;
+
+ my $bug;
+ if (blessed $bug_id) {
+ # We got a bug object passed in, use it
+ $bug = $bug_id;
+ $bug->check_is_visible;
+ }
+ else {
+ # We got a bug id passed in, check it and get the bug object
+ $bug = Bugzilla::Bug->check({ id => $bug_id });
+ }
+
+ return $bug->id;
+}
+
+sub _check_value {
+ my ($class, $uri) = @_;
+
+ my $value = $uri->as_string;
+
+ if (!$value) {
+ ThrowCodeError('param_required',
+ { function => 'add_see_also', param => '$value' });
+ }
+
+ # We assume that the URL is an HTTP URL if there is no (something)://
+ # in front.
+ if (!$uri->scheme) {
+ # This works better than setting $uri->scheme('http'), because
+ # that creates URLs like "http:domain.com" and doesn't properly
+ # differentiate the path from the domain.
+ $uri = new URI("http://$value");
+ }
+ elsif ($uri->scheme ne 'http' && $uri->scheme ne 'https') {
+ ThrowUserError('bug_url_invalid', { url => $value, reason => 'http' });
+ }
+
+ # This stops the following edge cases from being accepted:
+ # * show_bug.cgi?id=1
+ # * /show_bug.cgi?id=1
+ # * http:///show_bug.cgi?id=1
+ if (!$uri->authority or $uri->path !~ m{/}) {
+ ThrowUserError('bug_url_invalid',
+ { url => $value, reason => 'path_only' });
+ }
+
+ if (length($uri->path) > MAX_BUG_URL_LENGTH) {
+ ThrowUserError('bug_url_too_long', { url => $uri->path });
+ }
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/Bugzilla.pm b/Bugzilla/BugUrl/Bugzilla.pm
new file mode 100644
index 0000000..4db37eb
--- /dev/null
+++ b/Bugzilla/BugUrl/Bugzilla.pm
@@ -0,0 +1,53 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::Bugzilla;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+use Bugzilla::Error;
+use Bugzilla::Util;
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+ return ($uri->path =~ /show_bug\.cgi$/) ? 1 : 0;
+}
+
+sub _check_value {
+ my ($class, $uri) = @_;
+
+ $uri = $class->SUPER::_check_value($uri);
+
+ my $bug_id = $uri->query_param('id');
+ # We don't currently allow aliases, because we can't check to see
+ # if somebody's putting both an alias link and a numeric ID link.
+ # When we start validating the URL by accessing the other Bugzilla,
+ # we can allow aliases.
+ detaint_natural($bug_id);
+ if (!$bug_id) {
+ my $value = $uri->as_string;
+ ThrowUserError('bug_url_invalid', { url => $value, reason => 'id' });
+ }
+
+ # Make sure that "id" is the only query parameter.
+ $uri->query("id=$bug_id");
+ # And remove any # part if there is one.
+ $uri->fragment(undef);
+
+ return $uri;
+}
+
+sub target_bug_id {
+ my ($self) = @_;
+ return new URI($self->name)->query_param('id');
+}
+
+1;
diff --git a/Bugzilla/BugUrl/Bugzilla/Local.pm b/Bugzilla/BugUrl/Bugzilla/Local.pm
new file mode 100644
index 0000000..9631716
--- /dev/null
+++ b/Bugzilla/BugUrl/Bugzilla/Local.pm
@@ -0,0 +1,92 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::Bugzilla::Local;
+use strict;
+use base qw(Bugzilla::BugUrl::Bugzilla);
+
+use Bugzilla::Error;
+use Bugzilla::Util;
+
+###############################
+#### Initialization ####
+###############################
+
+use constant VALIDATOR_DEPENDENCIES => {
+ value => ['bug_id'],
+};
+
+###############################
+#### Methods ####
+###############################
+
+sub ref_bug_url {
+ my $self = shift;
+
+ if (!exists $self->{ref_bug_url}) {
+ my $ref_bug_id = new URI($self->name)->query_param('id');
+ my $ref_bug = Bugzilla::Bug->check($ref_bug_id);
+ my $ref_value = $self->local_uri($self->bug_id);
+ $self->{ref_bug_url} =
+ new Bugzilla::BugUrl::Bugzilla::Local({ bug_id => $ref_bug->id,
+ value => $ref_value });
+ }
+ return $self->{ref_bug_url};
+}
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # Check if it is either a bug id number or an alias.
+ return 1 if $uri->as_string =~ m/^\w+$/;
+
+ # Check if it is a local Bugzilla uri and call
+ # Bugzilla::BugUrl::Bugzilla to check if it's a valid Bugzilla
+ # see also url.
+ my $canonical_local = URI->new($class->local_uri)->canonical;
+ if ($canonical_local->authority eq $uri->canonical->authority
+ and $canonical_local->path eq $uri->canonical->path)
+ {
+ return $class->SUPER::should_handle($uri);
+ }
+
+ return 0;
+}
+
+sub _check_value {
+ my ($class, $uri, undef, $params) = @_;
+
+ # At this point we are going to treat any word as a
+ # bug id/alias to the local Bugzilla.
+ my $value = $uri->as_string;
+ if ($value =~ m/^\w+$/) {
+ $uri = new URI($class->local_uri($value));
+ } else {
+ # It's not a word, then we have to check
+ # if it's a valid Bugzilla url.
+ $uri = $class->SUPER::_check_value($uri);
+ }
+
+ my $ref_bug_id = $uri->query_param('id');
+ my $ref_bug = Bugzilla::Bug->check($ref_bug_id);
+ my $self_bug_id = $params->{bug_id};
+ $params->{ref_bug} = $ref_bug;
+
+ if ($ref_bug->id == $self_bug_id) {
+ ThrowUserError('see_also_self_reference');
+ }
+
+ return $uri;
+}
+
+sub local_uri {
+ my ($self, $bug_id) = @_;
+ $bug_id ||= '';
+ return correct_urlbase() . "show_bug.cgi?id=$bug_id";
+}
+
+1;
diff --git a/Bugzilla/BugUrl/Debian.pm b/Bugzilla/BugUrl/Debian.pm
new file mode 100644
index 0000000..cce4c25
--- /dev/null
+++ b/Bugzilla/BugUrl/Debian.pm
@@ -0,0 +1,41 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::Debian;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # Debian BTS URLs can look like various things:
+ # http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1234
+ # http://bugs.debian.org/1234
+ return (lc($uri->authority) eq 'bugs.debian.org'
+ and (($uri->path =~ /bugreport\.cgi$/
+ and $uri->query_param('bug') =~ m|^\d+$|)
+ or $uri->path =~ m|^/\d+$|)) ? 1 : 0;
+}
+
+sub _check_value {
+ my $class = shift;
+
+ my $uri = $class->SUPER::_check_value(@_);
+
+ # This is the shortest standard URL form for Debian BTS URLs,
+ # and so we reduce all URLs to this.
+ $uri->path =~ m|^/(\d+)$| || $uri->query_param('bug') =~ m|^(\d+)$|;
+ $uri = new URI("http://bugs.debian.org/$1");
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/GitHub.pm b/Bugzilla/BugUrl/GitHub.pm
new file mode 100644
index 0000000..eeffa2e
--- /dev/null
+++ b/Bugzilla/BugUrl/GitHub.pm
@@ -0,0 +1,36 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::GitHub;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # GitHub issue URLs have only one form:
+ # https://github.com/USER_OR_TEAM_OR_ORGANIZATION_NAME/REPOSITORY_NAME/issues/111
+ return (lc($uri->authority) eq 'github.com'
+ and $uri->path =~ m|^/[^/]+/[^/]+/issues/\d+$|) ? 1 : 0;
+}
+
+sub _check_value {
+ my ($class, $uri) = @_;
+
+ $uri = $class->SUPER::_check_value($uri);
+
+ # GitHub HTTP URLs redirect to HTTPS, so just use the HTTPS scheme.
+ $uri->scheme('https');
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/Google.pm b/Bugzilla/BugUrl/Google.pm
new file mode 100644
index 0000000..9c49f0d
--- /dev/null
+++ b/Bugzilla/BugUrl/Google.pm
@@ -0,0 +1,40 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::Google;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # Google Code URLs only have one form:
+ # http(s)://code.google.com/p/PROJECT_NAME/issues/detail?id=1234
+ return (lc($uri->authority) eq 'code.google.com'
+ and $uri->path =~ m|^/p/[^/]+/issues/detail$|
+ and $uri->query_param('id') =~ /^\d+$/) ? 1 : 0;
+}
+
+sub _check_value {
+ my ($class, $uri) = @_;
+
+ $uri = $class->SUPER::_check_value($uri);
+
+ # While Google Code URLs can be either HTTP or HTTPS,
+ # always go with the HTTP scheme, as that's the default.
+ if ($uri->scheme eq 'https') {
+ $uri->scheme('http');
+ }
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/JIRA.pm b/Bugzilla/BugUrl/JIRA.pm
new file mode 100644
index 0000000..f5f7ee5
--- /dev/null
+++ b/Bugzilla/BugUrl/JIRA.pm
@@ -0,0 +1,38 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::JIRA;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # JIRA URLs have only one basic form (but the jira is optional):
+ # https://issues.apache.org/jira/browse/KEY-1234
+ # http://issues.example.com/browse/KEY-1234
+ return ($uri->path =~ m|/browse/[A-Z][A-Z]+-\d+$|) ? 1 : 0;
+}
+
+sub _check_value {
+ my $class = shift;
+
+ my $uri = $class->SUPER::_check_value(@_);
+
+ # Make sure there are no query parameters.
+ $uri->query(undef);
+ # And remove any # part if there is one.
+ $uri->fragment(undef);
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/Launchpad.pm b/Bugzilla/BugUrl/Launchpad.pm
new file mode 100644
index 0000000..2ae2c38
--- /dev/null
+++ b/Bugzilla/BugUrl/Launchpad.pm
@@ -0,0 +1,40 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::Launchpad;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # Launchpad bug URLs can look like various things:
+ # https://bugs.launchpad.net/ubuntu/+bug/1234
+ # https://launchpad.net/bugs/1234
+ # All variations end with either "/bugs/1234" or "/+bug/1234"
+ return ($uri->authority =~ /launchpad\.net$/
+ and $uri->path =~ m|bugs?/\d+$|) ? 1 : 0;
+}
+
+sub _check_value {
+ my ($class, $uri) = @_;
+
+ $uri = $class->SUPER::_check_value($uri);
+
+ # This is the shortest standard URL form for Launchpad bugs,
+ # and so we reduce all URLs to this.
+ $uri->path =~ m|bugs?/(\d+)$|;
+ $uri = new URI("https://launchpad.net/bugs/$1");
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/MantisBT.pm b/Bugzilla/BugUrl/MantisBT.pm
new file mode 100644
index 0000000..3d49ede
--- /dev/null
+++ b/Bugzilla/BugUrl/MantisBT.pm
@@ -0,0 +1,35 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::MantisBT;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # MantisBT URLs look like the following ('bugs' directory is optional):
+ # http://www.mantisbt.org/bugs/view.php?id=1234
+ return ($uri->path_query =~ m|view\.php\?id=\d+$|) ? 1 : 0;
+}
+
+sub _check_value {
+ my $class = shift;
+
+ my $uri = $class->SUPER::_check_value(@_);
+
+ # Remove any # part if there is one.
+ $uri->fragment(undef);
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/SourceForge.pm b/Bugzilla/BugUrl/SourceForge.pm
new file mode 100644
index 0000000..fcc7200
--- /dev/null
+++ b/Bugzilla/BugUrl/SourceForge.pm
@@ -0,0 +1,40 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::SourceForge;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # SourceForge tracker URLs have only one form:
+ # http://sourceforge.net/tracker/?func=detail&aid=111&group_id=111&atid=111
+ return (lc($uri->authority) eq 'sourceforge.net'
+ and $uri->path =~ m|/tracker/|
+ and $uri->query_param('func') eq 'detail'
+ and $uri->query_param('aid')
+ and $uri->query_param('group_id')
+ and $uri->query_param('atid')) ? 1 : 0;
+}
+
+sub _check_value {
+ my $class = shift;
+
+ my $uri = $class->SUPER::_check_value(@_);
+
+ # Remove any # part if there is one.
+ $uri->fragment(undef);
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/BugUrl/Trac.pm b/Bugzilla/BugUrl/Trac.pm
new file mode 100644
index 0000000..8f6e9cd
--- /dev/null
+++ b/Bugzilla/BugUrl/Trac.pm
@@ -0,0 +1,38 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::BugUrl::Trac;
+use strict;
+use base qw(Bugzilla::BugUrl);
+
+###############################
+#### Methods ####
+###############################
+
+sub should_handle {
+ my ($class, $uri) = @_;
+
+ # Trac URLs can look like various things:
+ # http://dev.mutt.org/trac/ticket/1234
+ # http://trac.roundcube.net/ticket/1484130
+ return ($uri->path =~ m|/ticket/\d+$|) ? 1 : 0;
+}
+
+sub _check_value {
+ my $class = shift;
+
+ my $uri = $class->SUPER::_check_value(@_);
+
+ # Make sure there are no query parameters.
+ $uri->query(undef);
+ # And remove any # part if there is one.
+ $uri->fragment(undef);
+
+ return $uri;
+}
+
+1;
diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm
new file mode 100644
index 0000000..7bb9d96
--- /dev/null
+++ b/Bugzilla/CGI.pm
@@ -0,0 +1,676 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::CGI;
+use strict;
+use base qw(CGI);
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Util;
+use Bugzilla::Search::Recent;
+
+use File::Basename;
+
+sub _init_bz_cgi_globals {
+ my $invocant = shift;
+ # We need to disable output buffering - see bug 179174
+ $| = 1;
+
+ # Ignore SIGTERM and SIGPIPE - this prevents DB corruption. If the user closes
+ # their browser window while a script is running, the web server sends these
+ # signals, and we don't want to die half way through a write.
+ $SIG{TERM} = 'IGNORE';
+ $SIG{PIPE} = 'IGNORE';
+
+ # We don't precompile any functions here, that's done specially in
+ # mod_perl code.
+ $invocant->_setup_symbols(qw(:no_xhtml :oldstyle_urls :private_tempfiles
+ :unique_headers));
+}
+
+BEGIN { __PACKAGE__->_init_bz_cgi_globals() if i_am_cgi(); }
+
+sub new {
+ my ($invocant, @args) = @_;
+ my $class = ref($invocant) || $invocant;
+
+ # Under mod_perl, CGI's global variables get reset on each request,
+ # so we need to set them up again every time.
+ $class->_init_bz_cgi_globals() if $ENV{MOD_PERL};
+
+ my $self = $class->SUPER::new(@args);
+
+ # Make sure our outgoing cookie list is empty on each invocation
+ $self->{Bugzilla_cookie_list} = [];
+
+ # Path-Info is of no use for Bugzilla and interacts badly with IIS.
+ # Moreover, it causes unexpected behaviors, such as totally breaking
+ # the rendering of pages.
+ my $script = basename($0);
+ if (my $path_info = $self->path_info) {
+ my @whitelist;
+ Bugzilla::Hook::process('path_info_whitelist', { whitelist => \@whitelist });
+ if (!grep($_ eq $script, @whitelist)) {
+ # IIS includes the full path to the script in PATH_INFO,
+ # so we have to extract the real PATH_INFO from it,
+ # else we will be redirected outside Bugzilla.
+ my $script_name = $self->script_name;
+ $path_info =~ s/^\Q$script_name\E//;
+ if ($path_info) {
+ print $self->redirect($self->url(-path => 0, -query => 1));
+ }
+ }
+ }
+
+ # Send appropriate charset
+ $self->charset(Bugzilla->params->{'utf8'} ? 'UTF-8' : '');
+
+ # Redirect to urlbase/sslbase if we are not viewing an attachment.
+ if ($self->url_is_attachment_base and $script ne 'attachment.cgi') {
+ $self->redirect_to_urlbase();
+ }
+
+ # Check for errors
+ # All of the Bugzilla code wants to do this, so do it here instead of
+ # in each script
+
+ my $err = $self->cgi_error;
+
+ if ($err) {
+ # Note that this error block is only triggered by CGI.pm for malformed
+ # multipart requests, and so should never happen unless there is a
+ # browser bug.
+
+ print $self->header(-status => $err);
+
+ # ThrowCodeError wants to print the header, so it grabs Bugzilla->cgi
+ # which creates a new Bugzilla::CGI object, which fails again, which
+ # ends up here, and calls ThrowCodeError, and then recurses forever.
+ # So don't use it.
+ # In fact, we can't use templates at all, because we need a CGI object
+ # to determine the template lang as well as the current url (from the
+ # template)
+ # Since this is an internal error which indicates a severe browser bug,
+ # just die.
+ die "CGI parsing error: $err";
+ }
+
+ return $self;
+}
+
+# We want this sorted plus the ability to exclude certain params
+sub canonicalise_query {
+ my ($self, @exclude) = @_;
+
+ # Reconstruct the URL by concatenating the sorted param=value pairs
+ my @parameters;
+ foreach my $key (sort($self->param())) {
+ # Leave this key out if it's in the exclude list
+ next if grep { $_ eq $key } @exclude;
+
+ # Remove the Boolean Charts for standard query.cgi fields
+ # They are listed in the query URL already
+ next if $key =~ /^(field|type|value)(-\d+){3}$/;
+
+ my $esc_key = url_quote($key);
+
+ foreach my $value ($self->param($key)) {
+ if (defined($value)) {
+ my $esc_value = url_quote($value);
+
+ push(@parameters, "$esc_key=$esc_value");
+ }
+ }
+ }
+
+ return join("&", @parameters);
+}
+
+sub clean_search_url {
+ my $self = shift;
+ # Delete any empty URL parameter.
+ my @cgi_params = $self->param;
+
+ foreach my $param (@cgi_params) {
+ if (defined $self->param($param) && $self->param($param) eq '') {
+ $self->delete($param);
+ $self->delete("${param}_type");
+ }
+
+ # Custom Search stuff is empty if it's "noop". We also keep around
+ # the old Boolean Chart syntax for backwards-compatibility.
+ if (($param =~ /\d-\d-\d/ || $param =~ /^[[:alpha:]]\d+$/)
+ && defined $self->param($param) && $self->param($param) eq 'noop')
+ {
+ $self->delete($param);
+ }
+
+ # Any "join" for custom search that's an AND can be removed, because
+ # that's the default.
+ if (($param =~ /^j\d+$/ || $param eq 'j_top')
+ && $self->param($param) eq 'AND')
+ {
+ $self->delete($param);
+ }
+ }
+
+ # Delete leftovers from the login form
+ $self->delete('Bugzilla_remember', 'GoAheadAndLogIn');
+
+ # Delete the token if we're not performing an action which needs it
+ unless ((defined $self->param('remtype')
+ && ($self->param('remtype') eq 'asdefault'
+ || $self->param('remtype') eq 'asnamed'))
+ || (defined $self->param('remaction')
+ && $self->param('remaction') eq 'forget'))
+ {
+ $self->delete("token");
+ }
+
+ foreach my $num (1,2,3) {
+ # If there's no value in the email field, delete the related fields.
+ if (!$self->param("email$num")) {
+ foreach my $field (qw(type assigned_to reporter qa_contact cc longdesc)) {
+ $self->delete("email$field$num");
+ }
+ }
+ }
+
+ # chfieldto is set to "Now" by default in query.cgi. But if none
+ # of the other chfield parameters are set, it's meaningless.
+ if (!defined $self->param('chfieldfrom') && !$self->param('chfield')
+ && !defined $self->param('chfieldvalue') && $self->param('chfieldto')
+ && lc($self->param('chfieldto')) eq 'now')
+ {
+ $self->delete('chfieldto');
+ }
+
+ # cmdtype "doit" is the default from query.cgi, but it's only meaningful
+ # if there's a remtype parameter.
+ if (defined $self->param('cmdtype') && $self->param('cmdtype') eq 'doit'
+ && !defined $self->param('remtype'))
+ {
+ $self->delete('cmdtype');
+ }
+
+ # "Reuse same sort as last time" is actually the default, so we don't
+ # need it in the URL.
+ if ($self->param('order')
+ && $self->param('order') eq 'Reuse same sort as last time')
+ {
+ $self->delete('order');
+ }
+
+ # list_id is added in buglist.cgi after calling clean_search_url,
+ # and doesn't need to be saved in saved searches.
+ $self->delete('list_id');
+
+ # no_redirect is used internally by redirect_search_url().
+ $self->delete('no_redirect');
+
+ # And now finally, if query_format is our only parameter, that
+ # really means we have no parameters, so we should delete query_format.
+ if ($self->param('query_format') && scalar($self->param()) == 1) {
+ $self->delete('query_format');
+ }
+}
+
+sub check_etag {
+ my ($self, $valid_etag) = @_;
+
+ # ETag support.
+ my $if_none_match = $self->http('If-None-Match');
+ return if !$if_none_match;
+
+ my @if_none = split(/[\s,]+/, $if_none_match);
+ foreach my $possible_etag (@if_none) {
+ # remove quotes from begin and end of the string
+ $possible_etag =~ s/^\"//g;
+ $possible_etag =~ s/\"$//g;
+ if ($possible_etag eq $valid_etag or $possible_etag eq '*') {
+ print $self->header(-ETag => $possible_etag,
+ -status => '304 Not Modified');
+ exit;
+ }
+ }
+}
+
+# Have to add the cookies in.
+sub multipart_start {
+ my $self = shift;
+
+ my %args = @_;
+
+ # CGI.pm::multipart_start doesn't honour its own charset information, so
+ # we do it ourselves here
+ if (defined $self->charset() && defined $args{-type}) {
+ # Remove any existing charset specifier
+ $args{-type} =~ s/;.*$//;
+ # and add the specified one
+ $args{-type} .= '; charset=' . $self->charset();
+ }
+
+ my $headers = $self->SUPER::multipart_start(%args);
+ # Eliminate the one extra CRLF at the end.
+ $headers =~ s/$CGI::CRLF$//;
+ # Add the cookies. We have to do it this way instead of
+ # passing them to multpart_start, because CGI.pm's multipart_start
+ # doesn't understand a '-cookie' argument pointing to an arrayref.
+ foreach my $cookie (@{$self->{Bugzilla_cookie_list}}) {
+ $headers .= "Set-Cookie: ${cookie}${CGI::CRLF}";
+ }
+ $headers .= $CGI::CRLF;
+ $self->{_multipart_in_progress} = 1;
+ return $headers;
+}
+
+sub close_standby_message {
+ my ($self, $contenttype, $disposition) = @_;
+
+ if ($self->{_multipart_in_progress}) {
+ print $self->multipart_end();
+ print $self->multipart_start(-type => $contenttype,
+ -content_disposition => $disposition);
+ }
+ else {
+ print $self->header(-type => $contenttype,
+ -content_disposition => $disposition);
+ }
+}
+
+# Override header so we can add the cookies in
+sub header {
+ my $self = shift;
+ my $user = Bugzilla->user;
+
+ # If there's only one parameter, then it's a Content-Type.
+ if (scalar(@_) == 1) {
+ # Since we're adding parameters below, we have to name it.
+ unshift(@_, '-type' => shift(@_));
+ }
+
+ if (!$user->id && $user->authorizer->can_login
+ && !$self->cookie('Bugzilla_login_request_cookie'))
+ {
+ my %args;
+ $args{'-secure'} = 1 if Bugzilla->params->{ssl_redirect};
+
+ $self->send_cookie(-name => 'Bugzilla_login_request_cookie',
+ -value => generate_random_password(),
+ -httponly => 1,
+ %args);
+ }
+
+ # Add the cookies in if we have any
+ if (scalar(@{$self->{Bugzilla_cookie_list}})) {
+ unshift(@_, '-cookie' => $self->{Bugzilla_cookie_list});
+ }
+
+ # Add Strict-Transport-Security (STS) header if this response
+ # is over SSL and the strict_transport_security param is turned on.
+ if ($self->https && !$self->url_is_attachment_base
+ && Bugzilla->params->{'strict_transport_security'} ne 'off')
+ {
+ my $sts_opts = 'max-age=' . MAX_STS_AGE;
+ if (Bugzilla->params->{'strict_transport_security'}
+ eq 'include_subdomains')
+ {
+ $sts_opts .= '; includeSubDomains';
+ }
+ unshift(@_, '-strict_transport_security' => $sts_opts);
+ }
+
+ # Add X-Frame-Options header to prevent framing and subsequent
+ # possible clickjacking problems.
+ unless ($self->url_is_attachment_base) {
+ unshift(@_, '-x_frame_options' => 'SAMEORIGIN');
+ }
+
+ # Add X-XSS-Protection header to prevent simple XSS attacks
+ # and enforce the blocking (rather than the rewriting) mode.
+ unshift(@_, '-x_xss_protection' => '1; mode=block');
+
+ # Add X-Content-Type-Options header to prevent browsers sniffing
+ # the MIME type away from the declared Content-Type.
+ unshift(@_, '-x_content_type_options' => 'nosniff');
+
+ return $self->SUPER::header(@_) || "";
+}
+
+sub param {
+ my $self = shift;
+
+ # When we are just requesting the value of a parameter...
+ if (scalar(@_) == 1) {
+ my @result = $self->SUPER::param(@_);
+
+ # Also look at the URL parameters, after we look at the POST
+ # parameters. This is to allow things like login-form submissions
+ # with URL parameters in the form's "target" attribute.
+ if (!scalar(@result)
+ && $self->request_method && $self->request_method eq 'POST')
+ {
+ # Some servers fail to set the QUERY_STRING parameter, which
+ # causes undef issues
+ $ENV{'QUERY_STRING'} = '' unless exists $ENV{'QUERY_STRING'};
+ @result = $self->SUPER::url_param(@_);
+ }
+
+ # Fix UTF-8-ness of input parameters.
+ if (Bugzilla->params->{'utf8'}) {
+ @result = map { _fix_utf8($_) } @result;
+ }
+
+ return wantarray ? @result : $result[0];
+ }
+ # And for various other functions in CGI.pm, we need to correctly
+ # return the URL parameters in addition to the POST parameters when
+ # asked for the list of parameters.
+ elsif (!scalar(@_) && $self->request_method
+ && $self->request_method eq 'POST')
+ {
+ my @post_params = $self->SUPER::param;
+ my @url_params = $self->url_param;
+ my %params = map { $_ => 1 } (@post_params, @url_params);
+ return keys %params;
+ }
+
+ return $self->SUPER::param(@_);
+}
+
+sub _fix_utf8 {
+ my $input = shift;
+ # The is_utf8 is here in case CGI gets smart about utf8 someday.
+ utf8::decode($input) if defined $input && !ref $input && !utf8::is_utf8($input);
+ return $input;
+}
+
+sub should_set {
+ my ($self, $param) = @_;
+ my $set = (defined $self->param($param)
+ or defined $self->param("defined_$param"))
+ ? 1 : 0;
+ return $set;
+}
+
+# The various parts of Bugzilla which create cookies don't want to have to
+# pass them around to all of the callers. Instead, store them locally here,
+# and then output as required from |header|.
+sub send_cookie {
+ my $self = shift;
+
+ # Move the param list into a hash for easier handling.
+ my %paramhash;
+ my @paramlist;
+ my ($key, $value);
+ while ($key = shift) {
+ $value = shift;
+ $paramhash{$key} = $value;
+ }
+
+ # Complain if -value is not given or empty (bug 268146).
+ if (!exists($paramhash{'-value'}) || !$paramhash{'-value'}) {
+ ThrowCodeError('cookies_need_value');
+ }
+
+ # Add the default path and the domain in.
+ $paramhash{'-path'} = Bugzilla->params->{'cookiepath'};
+ $paramhash{'-domain'} = Bugzilla->params->{'cookiedomain'}
+ if Bugzilla->params->{'cookiedomain'};
+
+ # Move the param list back into an array for the call to cookie().
+ foreach (keys(%paramhash)) {
+ unshift(@paramlist, $_ => $paramhash{$_});
+ }
+
+ push(@{$self->{'Bugzilla_cookie_list'}}, $self->cookie(@paramlist));
+}
+
+# Cookies are removed by setting an expiry date in the past.
+# This method is a send_cookie wrapper doing exactly this.
+sub remove_cookie {
+ my $self = shift;
+ my ($cookiename) = (@_);
+
+ # Expire the cookie, giving a non-empty dummy value (bug 268146).
+ $self->send_cookie('-name' => $cookiename,
+ '-expires' => 'Tue, 15-Sep-1998 21:49:00 GMT',
+ '-value' => 'X');
+}
+
+# This helps implement Bugzilla::Search::Recent, and also shortens search
+# URLs that get POSTed to buglist.cgi.
+sub redirect_search_url {
+ my $self = shift;
+
+ # If there is no parameter, there is nothing to do.
+ return unless $self->param;
+
+ # If we're retreiving an old list, we never need to redirect or
+ # do anything related to Bugzilla::Search::Recent.
+ return if $self->param('regetlastlist');
+
+ my $user = Bugzilla->user;
+
+ if ($user->id) {
+ # There are two conditions that could happen here--we could get a URL
+ # with no list id, and we could get a URL with a list_id that isn't
+ # ours.
+ my $list_id = $self->param('list_id');
+ if ($list_id) {
+ # If we have a valid list_id, no need to redirect or clean.
+ return if Bugzilla::Search::Recent->check_quietly(
+ { id => $list_id });
+ }
+ }
+ elsif ($self->request_method ne 'POST') {
+ # Logged-out users who do a GET don't get a list_id, don't get
+ # their URLs cleaned, and don't get redirected.
+ return;
+ }
+
+ my $no_redirect = $self->param('no_redirect');
+ $self->clean_search_url();
+
+ # Make sure we still have params still after cleaning otherwise we
+ # do not want to store a list_id for an empty search.
+ if ($user->id && $self->param) {
+ # Insert a placeholder Bugzilla::Search::Recent, so that we know what
+ # the id of the resulting search will be. This is then pulled out
+ # of the Referer header when viewing show_bug.cgi to know what
+ # bug list we came from.
+ my $recent_search = Bugzilla::Search::Recent->create_placeholder;
+ $self->param('list_id', $recent_search->id);
+ }
+
+ # Browsers which support history.replaceState do not need to be
+ # redirected. We can fix the URL on the fly.
+ return if $no_redirect;
+
+ # GET requests that lacked a list_id are always redirected. POST requests
+ # are only redirected if they're under the CGI_URI_LIMIT though.
+ my $self_url = $self->self_url();
+ if ($self->request_method() ne 'POST' or length($self_url) < CGI_URI_LIMIT) {
+ print $self->redirect(-url => $self_url);
+ exit;
+ }
+}
+
+sub redirect_to_https {
+ my $self = shift;
+ my $sslbase = Bugzilla->params->{'sslbase'};
+ # If this is a POST, we don't want ?POSTDATA in the query string.
+ # We expect the client to re-POST, which may be a violation of
+ # the HTTP spec, but the only time we're expecting it often is
+ # in the WebService, and WebService clients usually handle this
+ # correctly.
+ $self->delete('POSTDATA');
+ my $url = $sslbase . $self->url('-path_info' => 1, '-query' => 1,
+ '-relative' => 1);
+
+ # XML-RPC clients (SOAP::Lite at least) require a 301 to redirect properly
+ # and do not work with 302. Our redirect really is permanent anyhow, so
+ # it doesn't hurt to make it a 301.
+ print $self->redirect(-location => $url, -status => 301);
+
+ # When using XML-RPC with mod_perl, we need the headers sent immediately.
+ $self->r->rflush if $ENV{MOD_PERL};
+ exit;
+}
+
+# Redirect to the urlbase version of the current URL.
+sub redirect_to_urlbase {
+ my $self = shift;
+ my $path = $self->url('-path_info' => 1, '-query' => 1, '-relative' => 1);
+ print $self->redirect('-location' => correct_urlbase() . $path);
+ exit;
+}
+
+sub url_is_attachment_base {
+ my ($self, $id) = @_;
+ return 0 if !use_attachbase() or !i_am_cgi();
+ my $attach_base = Bugzilla->params->{'attachment_base'};
+ # If we're passed an id, we only want one specific attachment base
+ # for a particular bug. If we're not passed an ID, we just want to
+ # know if our current URL matches the attachment_base *pattern*.
+ my $regex;
+ if ($id) {
+ $attach_base =~ s/\%bugid\%/$id/;
+ $regex = quotemeta($attach_base);
+ }
+ else {
+ # In this circumstance we run quotemeta first because we need to
+ # insert an active regex meta-character afterward.
+ $regex = quotemeta($attach_base);
+ $regex =~ s/\\\%bugid\\\%/\\d+/;
+ }
+ $regex = "^$regex";
+ return ($self->url =~ $regex) ? 1 : 0;
+}
+
+##########################
+# Vars TIEHASH Interface #
+##########################
+
+# Fix the TIEHASH interface (scalar $cgi->Vars) to return and accept
+# arrayrefs.
+sub STORE {
+ my $self = shift;
+ my ($param, $value) = @_;
+ if (defined $value and ref $value eq 'ARRAY') {
+ return $self->param(-name => $param, -value => $value);
+ }
+ return $self->SUPER::STORE(@_);
+}
+
+sub FETCH {
+ my ($self, $param) = @_;
+ return $self if $param eq 'CGI'; # CGI.pm did this, so we do too.
+ my @result = $self->param($param);
+ return undef if !scalar(@result);
+ return $result[0] if scalar(@result) == 1;
+ return \@result;
+}
+
+# For the Vars TIEHASH interface: the normal CGI.pm DELETE doesn't return
+# the value deleted, but Perl's "delete" expects that value.
+sub DELETE {
+ my ($self, $param) = @_;
+ my $value = $self->FETCH($param);
+ $self->delete($param);
+ return $value;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::CGI - CGI handling for Bugzilla
+
+=head1 SYNOPSIS
+
+ use Bugzilla::CGI;
+
+ my $cgi = new Bugzilla::CGI();
+
+=head1 DESCRIPTION
+
+This package inherits from the standard CGI module, to provide additional
+Bugzilla-specific functionality. In general, see L<the CGI.pm docs|CGI> for
+documention.
+
+=head1 CHANGES FROM L<CGI.PM|CGI>
+
+Bugzilla::CGI has some differences from L<CGI.pm|CGI>.
+
+=over 4
+
+=item C<cgi_error> is automatically checked
+
+After creating the CGI object, C<Bugzilla::CGI> automatically checks
+I<cgi_error>, and throws a CodeError if a problem is detected.
+
+=back
+
+=head1 ADDITIONAL FUNCTIONS
+
+I<Bugzilla::CGI> also includes additional functions.
+
+=over 4
+
+=item C<canonicalise_query(@exclude)>
+
+This returns a sorted string of the parameters, suitable for use in a url.
+Values in C<@exclude> are not included in the result.
+
+=item C<send_cookie>
+
+This routine is identical to the cookie generation part of CGI.pm's C<cookie>
+routine, except that it knows about Bugzilla's cookie_path and cookie_domain
+parameters and takes them into account if necessary.
+This should be used by all Bugzilla code (instead of C<cookie> or the C<-cookie>
+argument to C<header>), so that under mod_perl the headers can be sent
+correctly, using C<print> or the mod_perl APIs as appropriate.
+
+To remove (expire) a cookie, use C<remove_cookie>.
+
+=item C<remove_cookie>
+
+This is a wrapper around send_cookie, setting an expiry date in the past,
+effectively removing the cookie.
+
+As its only argument, it takes the name of the cookie to expire.
+
+=item C<redirect_to_https>
+
+This routine redirects the client to the https version of the page that
+they're looking at, using the C<sslbase> parameter for the redirection.
+
+Generally you should use L<Bugzilla::Util/do_ssl_redirect_if_required>
+instead of calling this directly.
+
+=item C<redirect_to_urlbase>
+
+Redirects from the current URL to one prefixed by the urlbase parameter.
+
+=item C<multipart_start>
+
+Starts a new part of the multipart document using the specified MIME type.
+If not specified, text/html is assumed.
+
+=item C<close_standby_message>
+
+Ends a part of the multipart document, and starts another part.
+
+=back
+
+=head1 SEE ALSO
+
+L<CGI|CGI>, L<CGI::Cookie|CGI::Cookie>
diff --git a/Bugzilla/Chart.pm b/Bugzilla/Chart.pm
new file mode 100644
index 0000000..0a65576
--- /dev/null
+++ b/Bugzilla/Chart.pm
@@ -0,0 +1,429 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+
+# This module represents a chart.
+#
+# Note that it is perfectly legal for the 'lines' member variable of this
+# class (which is an array of Bugzilla::Series objects) to have empty members
+# in it. If this is true, the 'labels' array will also have empty members at
+# the same points.
+package Bugzilla::Chart;
+
+use Bugzilla::Error;
+use Bugzilla::Util;
+use Bugzilla::Series;
+
+use Date::Format;
+use Date::Parse;
+use List::Util qw(max);
+
+sub new {
+ my $invocant = shift;
+ my $class = ref($invocant) || $invocant;
+
+ # Create a ref to an empty hash and bless it
+ my $self = {};
+ bless($self, $class);
+
+ if ($#_ == 0) {
+ # Construct from a CGI object.
+ $self->init($_[0]);
+ }
+ else {
+ die("CGI object not passed in - invalid number of args \($#_\)($_)");
+ }
+
+ return $self;
+}
+
+sub init {
+ my $self = shift;
+ my $cgi = shift;
+
+ # The data structure is a list of lists (lines) of Series objects.
+ # There is a separate list for the labels.
+ #
+ # The URL encoding is:
+ # line0=67&line0=73&line1=81&line2=67...
+ # &label0=B+/+R+/+CONFIRMED&label1=...
+ # &select0=1&select3=1...
+ # &cumulate=1&datefrom=2002-02-03&dateto=2002-04-04&ctype=html...
+ # &gt=1&labelgt=Grand+Total
+ foreach my $param ($cgi->param()) {
+ # Store all the lines
+ if ($param =~ /^line(\d+)$/) {
+ foreach my $series_id ($cgi->param($param)) {
+ detaint_natural($series_id)
+ || ThrowCodeError("invalid_series_id");
+ my $series = new Bugzilla::Series($series_id);
+ push(@{$self->{'lines'}[$1]}, $series) if $series;
+ }
+ }
+
+ # Store all the labels
+ if ($param =~ /^label(\d+)$/) {
+ $self->{'labels'}[$1] = $cgi->param($param);
+ }
+ }
+
+ # Store the miscellaneous metadata
+ $self->{'cumulate'} = $cgi->param('cumulate') ? 1 : 0;
+ $self->{'gt'} = $cgi->param('gt') ? 1 : 0;
+ $self->{'labelgt'} = $cgi->param('labelgt');
+ $self->{'datefrom'} = $cgi->param('datefrom');
+ $self->{'dateto'} = $cgi->param('dateto');
+
+ # If we are cumulating, a grand total makes no sense
+ $self->{'gt'} = 0 if $self->{'cumulate'};
+
+ # Make sure the dates are ones we are able to interpret
+ foreach my $date ('datefrom', 'dateto') {
+ if ($self->{$date}) {
+ $self->{$date} = str2time($self->{$date})
+ || ThrowUserError("illegal_date", { date => $self->{$date}});
+ }
+ }
+
+ # datefrom can't be after dateto
+ if ($self->{'datefrom'} && $self->{'dateto'} &&
+ $self->{'datefrom'} > $self->{'dateto'})
+ {
+ ThrowUserError("misarranged_dates",
+ {'datefrom' => $cgi->param('datefrom'),
+ 'dateto' => $cgi->param('dateto')});
+ }
+}
+
+# Alter Chart so that the selected series are added to it.
+sub add {
+ my $self = shift;
+ my @series_ids = @_;
+
+ # Get the current size of the series; required for adding Grand Total later
+ my $current_size = scalar($self->getSeriesIDs());
+
+ # Count the number of added series
+ my $added = 0;
+ # Create new Series and push them on to the list of lines.
+ # Note that new lines have no label; the display template is responsible
+ # for inventing something sensible.
+ foreach my $series_id (@series_ids) {
+ my $series = new Bugzilla::Series($series_id);
+ if ($series) {
+ push(@{$self->{'lines'}}, [$series]);
+ push(@{$self->{'labels'}}, "");
+ $added++;
+ }
+ }
+
+ # If we are going from < 2 to >= 2 series, add the Grand Total line.
+ if (!$self->{'gt'}) {
+ if ($current_size < 2 &&
+ $current_size + $added >= 2)
+ {
+ $self->{'gt'} = 1;
+ }
+ }
+}
+
+# Alter Chart so that the selections are removed from it.
+sub remove {
+ my $self = shift;
+ my @line_ids = @_;
+
+ foreach my $line_id (@line_ids) {
+ if ($line_id == 65536) {
+ # Magic value - delete Grand Total.
+ $self->{'gt'} = 0;
+ }
+ else {
+ delete($self->{'lines'}->[$line_id]);
+ delete($self->{'labels'}->[$line_id]);
+ }
+ }
+}
+
+# Alter Chart so that the selections are summed.
+sub sum {
+ my $self = shift;
+ my @line_ids = @_;
+
+ # We can't add the Grand Total to things.
+ @line_ids = grep(!/^65536$/, @line_ids);
+
+ # We can't add less than two things.
+ return if scalar(@line_ids) < 2;
+
+ my @series;
+ my $label = "";
+ my $biggestlength = 0;
+
+ # We rescue the Series objects of all the series involved in the sum.
+ foreach my $line_id (@line_ids) {
+ my @line = @{$self->{'lines'}->[$line_id]};
+
+ foreach my $series (@line) {
+ push(@series, $series);
+ }
+
+ # We keep the label that labels the line with the most series.
+ if (scalar(@line) > $biggestlength) {
+ $biggestlength = scalar(@line);
+ $label = $self->{'labels'}->[$line_id];
+ }
+ }
+
+ $self->remove(@line_ids);
+
+ push(@{$self->{'lines'}}, \@series);
+ push(@{$self->{'labels'}}, $label);
+}
+
+sub data {
+ my $self = shift;
+ $self->{'_data'} ||= $self->readData();
+ return $self->{'_data'};
+}
+
+# Convert the Chart's data into a plottable form in $self->{'_data'}.
+sub readData {
+ my $self = shift;
+ my @data;
+ my @maxvals;
+
+ # Note: you get a bad image if getSeriesIDs returns nothing
+ # We need to handle errors better.
+ my $series_ids = join(",", $self->getSeriesIDs());
+
+ return [] unless $series_ids;
+
+ # Work out the date boundaries for our data.
+ my $dbh = Bugzilla->dbh;
+
+ # The date used is the one given if it's in a sensible range; otherwise,
+ # it's the earliest or latest date in the database as appropriate.
+ my $datefrom = $dbh->selectrow_array("SELECT MIN(series_date) " .
+ "FROM series_data " .
+ "WHERE series_id IN ($series_ids)");
+ $datefrom = str2time($datefrom);
+
+ if ($self->{'datefrom'} && $self->{'datefrom'} > $datefrom) {
+ $datefrom = $self->{'datefrom'};
+ }
+
+ my $dateto = $dbh->selectrow_array("SELECT MAX(series_date) " .
+ "FROM series_data " .
+ "WHERE series_id IN ($series_ids)");
+ $dateto = str2time($dateto);
+
+ if ($self->{'dateto'} && $self->{'dateto'} < $dateto) {
+ $dateto = $self->{'dateto'};
+ }
+
+ # Convert UNIX times back to a date format usable for SQL queries.
+ my $sql_from = time2str('%Y-%m-%d', $datefrom);
+ my $sql_to = time2str('%Y-%m-%d', $dateto);
+
+ # Prepare the query which retrieves the data for each series
+ my $query = "SELECT " . $dbh->sql_to_days('series_date') . " - " .
+ $dbh->sql_to_days('?') . ", series_value " .
+ "FROM series_data " .
+ "WHERE series_id = ? " .
+ "AND series_date >= ?";
+ if ($dateto) {
+ $query .= " AND series_date <= ?";
+ }
+
+ my $sth = $dbh->prepare($query);
+
+ my $gt_index = $self->{'gt'} ? scalar(@{$self->{'lines'}}) : undef;
+ my $line_index = 0;
+
+ $maxvals[$gt_index] = 0 if $gt_index;
+
+ my @datediff_total;
+
+ foreach my $line (@{$self->{'lines'}}) {
+ # Even if we end up with no data, we need an empty arrayref to prevent
+ # errors in the PNG-generating code
+ $data[$line_index] = [];
+ $maxvals[$line_index] = 0;
+
+ foreach my $series (@$line) {
+
+ # Get the data for this series and add it on
+ if ($dateto) {
+ $sth->execute($sql_from, $series->{'series_id'}, $sql_from, $sql_to);
+ }
+ else {
+ $sth->execute($sql_from, $series->{'series_id'}, $sql_from);
+ }
+ my $points = $sth->fetchall_arrayref();
+
+ foreach my $point (@$points) {
+ my ($datediff, $value) = @$point;
+ $data[$line_index][$datediff] ||= 0;
+ $data[$line_index][$datediff] += $value;
+ if ($data[$line_index][$datediff] > $maxvals[$line_index]) {
+ $maxvals[$line_index] = $data[$line_index][$datediff];
+ }
+
+ $datediff_total[$datediff] += $value;
+
+ # Add to the grand total, if we are doing that
+ if ($gt_index) {
+ $data[$gt_index][$datediff] ||= 0;
+ $data[$gt_index][$datediff] += $value;
+ if ($data[$gt_index][$datediff] > $maxvals[$gt_index]) {
+ $maxvals[$gt_index] = $data[$gt_index][$datediff];
+ }
+ }
+ }
+ }
+
+ # We are done with the series making up this line, go to the next one
+ $line_index++;
+ }
+
+ # calculate maximum y value
+ if ($self->{'cumulate'}) {
+ # Make sure we do not try to take the max of an array with undef values
+ my @processed_datediff;
+ while (@datediff_total) {
+ my $datediff = shift @datediff_total;
+ push @processed_datediff, $datediff if defined($datediff);
+ }
+ $self->{'y_max_value'} = max(@processed_datediff);
+ }
+ else {
+ $self->{'y_max_value'} = max(@maxvals);
+ }
+ $self->{'y_max_value'} |= 1; # For log()
+
+ # Align the max y value:
+ # For one- or two-digit numbers, increase y_max_value until divisible by 8
+ # For larger numbers, see the comments below to figure out what's going on
+ if ($self->{'y_max_value'} < 100) {
+ do {
+ ++$self->{'y_max_value'};
+ } while ($self->{'y_max_value'} % 8 != 0);
+ }
+ else {
+ # First, get the # of digits in the y_max_value
+ my $num_digits = 1+int(log($self->{'y_max_value'})/log(10));
+
+ # We want to zero out all but the top 2 digits
+ my $mask_length = $num_digits - 2;
+ $self->{'y_max_value'} /= 10**$mask_length;
+ $self->{'y_max_value'} = int($self->{'y_max_value'});
+ $self->{'y_max_value'} *= 10**$mask_length;
+
+ # Add 10^$mask_length to the max value
+ # Continue to increase until it's divisible by 8 * 10^($mask_length-1)
+ # (Throwing in the -1 keeps at least the smallest digit at zero)
+ do {
+ $self->{'y_max_value'} += 10**$mask_length;
+ } while ($self->{'y_max_value'} % (8*(10**($mask_length-1))) != 0);
+ }
+
+
+ # Add the x-axis labels into the data structure
+ my $date_progression = generateDateProgression($datefrom, $dateto);
+ unshift(@data, $date_progression);
+
+ if ($self->{'gt'}) {
+ # Add Grand Total to label list
+ push(@{$self->{'labels'}}, $self->{'labelgt'});
+
+ $data[$gt_index] ||= [];
+ }
+
+ return \@data;
+}
+
+# Flatten the data structure into a list of series_ids
+sub getSeriesIDs {
+ my $self = shift;
+ my @series_ids;
+
+ foreach my $line (@{$self->{'lines'}}) {
+ foreach my $series (@$line) {
+ push(@series_ids, $series->{'series_id'});
+ }
+ }
+
+ return @series_ids;
+}
+
+# Class method to get the data necessary to populate the "select series"
+# widgets on various pages.
+sub getVisibleSeries {
+ my %cats;
+
+ my $grouplist = Bugzilla->user->groups_as_string;
+
+ # Get all visible series
+ my $dbh = Bugzilla->dbh;
+ my $serieses = $dbh->selectall_arrayref("SELECT cc1.name, cc2.name, " .
+ "series.name, series.series_id " .
+ "FROM series " .
+ "INNER JOIN series_categories AS cc1 " .
+ " ON series.category = cc1.id " .
+ "INNER JOIN series_categories AS cc2 " .
+ " ON series.subcategory = cc2.id " .
+ "LEFT JOIN category_group_map AS cgm " .
+ " ON series.category = cgm.category_id " .
+ " AND cgm.group_id NOT IN($grouplist) " .
+ "WHERE creator = ? OR (is_public = 1 AND cgm.category_id IS NULL) " .
+ $dbh->sql_group_by('series.series_id', 'cc1.name, cc2.name, ' .
+ 'series.name'),
+ undef, Bugzilla->user->id);
+ foreach my $series (@$serieses) {
+ my ($cat, $subcat, $name, $series_id) = @$series;
+ $cats{$cat}{$subcat}{$name} = $series_id;
+ }
+
+ return \%cats;
+}
+
+sub generateDateProgression {
+ my ($datefrom, $dateto) = @_;
+ my @progression;
+
+ $dateto = $dateto || time();
+ my $oneday = 60 * 60 * 24;
+
+ # When the from and to dates are converted by str2time(), you end up with
+ # a time figure representing midnight at the beginning of that day. We
+ # adjust the times by 1/3 and 2/3 of a day respectively to prevent
+ # edge conditions in time2str().
+ $datefrom += $oneday / 3;
+ $dateto += (2 * $oneday) / 3;
+
+ while ($datefrom < $dateto) {
+ push (@progression, time2str("%Y-%m-%d", $datefrom));
+ $datefrom += $oneday;
+ }
+
+ return \@progression;
+}
+
+sub dump {
+ my $self = shift;
+
+ # Make sure we've read in our data
+ my $data = $self->data;
+
+ require Data::Dumper;
+ say "<pre>Bugzilla::Chart object:";
+ print html_quote(Data::Dumper::Dumper($self));
+ print "</pre>";
+}
+
+1;
diff --git a/Bugzilla/Classification.pm b/Bugzilla/Classification.pm
new file mode 100644
index 0000000..2b35a88
--- /dev/null
+++ b/Bugzilla/Classification.pm
@@ -0,0 +1,261 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+
+package Bugzilla::Classification;
+
+use Bugzilla::Constants;
+use Bugzilla::Field;
+use Bugzilla::Util;
+use Bugzilla::Error;
+use Bugzilla::Product;
+
+use base qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object Exporter);
+@Bugzilla::Classification::EXPORT = qw(sort_products_by_classification);
+
+###############################
+#### Initialization ####
+###############################
+
+use constant DB_TABLE => 'classifications';
+use constant LIST_ORDER => 'sortkey, name';
+
+use constant DB_COLUMNS => qw(
+ id
+ name
+ description
+ sortkey
+);
+
+use constant UPDATE_COLUMNS => qw(
+ name
+ description
+ sortkey
+);
+
+use constant VALIDATORS => {
+ name => \&_check_name,
+ description => \&_check_description,
+ sortkey => \&_check_sortkey,
+};
+
+###############################
+#### Constructors #####
+###############################
+sub remove_from_db {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+
+ ThrowUserError("classification_not_deletable") if ($self->id == 1);
+
+ $dbh->bz_start_transaction();
+ # Reclassify products to the default classification, if needed.
+ $dbh->do("UPDATE products SET classification_id = 1
+ WHERE classification_id = ?", undef, $self->id);
+
+ $self->SUPER::remove_from_db();
+
+ $dbh->bz_commit_transaction();
+
+}
+
+###############################
+#### Validators ####
+###############################
+
+sub _check_name {
+ my ($invocant, $name) = @_;
+
+ $name = trim($name);
+ $name || ThrowUserError('classification_not_specified');
+
+ if (length($name) > MAX_CLASSIFICATION_SIZE) {
+ ThrowUserError('classification_name_too_long', {'name' => $name});
+ }
+
+ my $classification = new Bugzilla::Classification({name => $name});
+ if ($classification && (!ref $invocant || $classification->id != $invocant->id)) {
+ ThrowUserError("classification_already_exists", { name => $classification->name });
+ }
+ return $name;
+}
+
+sub _check_description {
+ my ($invocant, $description) = @_;
+
+ $description = trim($description || '');
+ return $description;
+}
+
+sub _check_sortkey {
+ my ($invocant, $sortkey) = @_;
+
+ $sortkey ||= 0;
+ my $stored_sortkey = $sortkey;
+ if (!detaint_natural($sortkey) || $sortkey > MAX_SMALLINT) {
+ ThrowUserError('classification_invalid_sortkey', { 'sortkey' => $stored_sortkey });
+ }
+ return $sortkey;
+}
+
+#####################################
+# Implement Bugzilla::Field::Choice #
+#####################################
+
+use constant FIELD_NAME => 'classification';
+use constant is_default => 0;
+use constant is_active => 1;
+
+###############################
+#### Methods ####
+###############################
+
+sub set_name { $_[0]->set('name', $_[1]); }
+sub set_description { $_[0]->set('description', $_[1]); }
+sub set_sortkey { $_[0]->set('sortkey', $_[1]); }
+
+sub product_count {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+
+ if (!defined $self->{'product_count'}) {
+ $self->{'product_count'} = $dbh->selectrow_array(q{
+ SELECT COUNT(*) FROM products
+ WHERE classification_id = ?}, undef, $self->id) || 0;
+ }
+ return $self->{'product_count'};
+}
+
+sub products {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+
+ if (!$self->{'products'}) {
+ my $product_ids = $dbh->selectcol_arrayref(q{
+ SELECT id FROM products
+ WHERE classification_id = ?
+ ORDER BY name}, undef, $self->id);
+
+ $self->{'products'} = Bugzilla::Product->new_from_list($product_ids);
+ }
+ return $self->{'products'};
+}
+
+###############################
+#### Accessors ####
+###############################
+
+sub description { return $_[0]->{'description'}; }
+sub sortkey { return $_[0]->{'sortkey'}; }
+
+
+###############################
+#### Helpers ####
+###############################
+
+# This function is a helper to sort products to be listed
+# in global/choose-product.html.tmpl.
+
+sub sort_products_by_classification {
+ my $products = shift;
+ my $list;
+
+ if (Bugzilla->params->{'useclassification'}) {
+ my $class = {};
+ # Get all classifications with at least one product.
+ foreach my $product (@$products) {
+ $class->{$product->classification_id}->{'object'} ||=
+ new Bugzilla::Classification($product->classification_id);
+ # Nice way to group products per classification, without querying
+ # the DB again.
+ push(@{$class->{$product->classification_id}->{'products'}}, $product);
+ }
+ $list = [sort {$a->{'object'}->sortkey <=> $b->{'object'}->sortkey
+ || lc($a->{'object'}->name) cmp lc($b->{'object'}->name)}
+ (values %$class)];
+ }
+ else {
+ $list = [{object => undef, products => $products}];
+ }
+ return $list;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::Classification - Bugzilla classification class.
+
+=head1 SYNOPSIS
+
+ use Bugzilla::Classification;
+
+ my $classification = new Bugzilla::Classification(1);
+ my $classification = new Bugzilla::Classification({name => 'Acme'});
+
+ my $id = $classification->id;
+ my $name = $classification->name;
+ my $description = $classification->description;
+ my $sortkey = $classification->sortkey;
+ my $product_count = $classification->product_count;
+ my $products = $classification->products;
+
+=head1 DESCRIPTION
+
+Classification.pm represents a classification 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::Classification> are listed
+below.
+
+A Classification is a higher-level grouping of Products.
+
+=head1 METHODS
+
+=over
+
+=item C<product_count()>
+
+ Description: Returns the total number of products that belong to
+ the classification.
+
+ Params: none.
+
+ Returns: Integer - The total of products inside the classification.
+
+=item C<products>
+
+ Description: Returns all products of the classification.
+
+ Params: none.
+
+ Returns: A reference to an array of Bugzilla::Product objects.
+
+=back
+
+=head1 SUBROUTINES
+
+=over
+
+=item C<sort_products_by_classification>
+
+ Description: This is a helper which returns a list of products sorted
+ by classification in a form suitable to be passed to the
+ global/choose-product.html.tmpl template.
+
+ Params: An arrayref of product objects.
+
+ Returns: An arrayref of hashes suitable to be passed to
+ global/choose-product.html.tmpl.
+
+=back
+
+=cut
diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm
new file mode 100644
index 0000000..30ec1cb
--- /dev/null
+++ b/Bugzilla/Comment.pm
@@ -0,0 +1,419 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+
+package Bugzilla::Comment;
+
+use base qw(Bugzilla::Object);
+
+use Bugzilla::Attachment;
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::User;
+use Bugzilla::Util;
+
+use Scalar::Util qw(blessed);
+
+###############################
+#### Initialization ####
+###############################
+
+# Creation and updating of comments are audited in longdescs
+# and bugs_activity respectively instead of audit_log.
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
+
+use constant DB_COLUMNS => qw(
+ comment_id
+ bug_id
+ who
+ bug_when
+ work_time
+ thetext
+ isprivate
+ already_wrapped
+ type
+ extra_data
+);
+
+use constant UPDATE_COLUMNS => qw(
+ isprivate
+ type
+ extra_data
+);
+
+use constant DB_TABLE => 'longdescs';
+use constant ID_FIELD => 'comment_id';
+# In some rare cases, two comments can have identical timestamps. If
+# this happens, we want to be sure that the comment added later shows up
+# later in the sequence.
+use constant LIST_ORDER => 'bug_when, comment_id';
+
+use constant VALIDATORS => {
+ bug_id => \&_check_bug_id,
+ who => \&_check_who,
+ bug_when => \&_check_bug_when,
+ work_time => \&_check_work_time,
+ thetext => \&_check_thetext,
+ isprivate => \&_check_isprivate,
+ extra_data => \&_check_extra_data,
+ type => \&_check_type,
+};
+
+use constant VALIDATOR_DEPENDENCIES => {
+ extra_data => ['type'],
+ bug_id => ['who'],
+ work_time => ['who', 'bug_id'],
+ isprivate => ['who'],
+};
+
+#########################
+# Database Manipulation #
+#########################
+
+sub update {
+ my $self = shift;
+ my $changes = $self->SUPER::update(@_);
+ $self->bug->_sync_fulltext( update_comments => 1);
+ return $changes;
+}
+
+# Speeds up displays of comment lists by loading all ->author objects
+# at once for a whole list.
+sub preload {
+ my ($class, $comments) = @_;
+ my %user_ids = map { $_->{who} => 1 } @$comments;
+ my $users = Bugzilla::User->new_from_list([keys %user_ids]);
+ my %user_map = map { $_->id => $_ } @$users;
+ foreach my $comment (@$comments) {
+ $comment->{author} = $user_map{$comment->{who}};
+ }
+}
+
+###############################
+#### Accessors ######
+###############################
+
+sub already_wrapped { return $_[0]->{'already_wrapped'}; }
+sub body { return $_[0]->{'thetext'}; }
+sub bug_id { return $_[0]->{'bug_id'}; }
+sub creation_ts { return $_[0]->{'bug_when'}; }
+sub is_private { return $_[0]->{'isprivate'}; }
+sub work_time {
+ # Work time is returned as a string (see bug 607909)
+ return 0 if $_[0]->{'work_time'} + 0 == 0;
+ return $_[0]->{'work_time'};
+}
+sub type { return $_[0]->{'type'}; }
+sub extra_data { return $_[0]->{'extra_data'} }
+
+sub bug {
+ my $self = shift;
+ require Bugzilla::Bug;
+ $self->{bug} ||= new Bugzilla::Bug($self->bug_id);
+ return $self->{bug};
+}
+
+sub is_about_attachment {
+ my ($self) = @_;
+ return 1 if ($self->type == CMT_ATTACHMENT_CREATED
+ or $self->type == CMT_ATTACHMENT_UPDATED);
+ return 0;
+}
+
+sub attachment {
+ my ($self) = @_;
+ return undef if not $self->is_about_attachment;
+ $self->{attachment} ||= new Bugzilla::Attachment($self->extra_data);
+ return $self->{attachment};
+}
+
+sub author {
+ my $self = shift;
+ $self->{'author'} ||= new Bugzilla::User($self->{'who'});
+ return $self->{'author'};
+}
+
+sub body_full {
+ my ($self, $params) = @_;
+ $params ||= {};
+ my $template = Bugzilla->template_inner;
+ my $body;
+ if ($self->type) {
+ $template->process("bug/format_comment.txt.tmpl",
+ { comment => $self, %$params }, \$body)
+ || ThrowTemplateError($template->error());
+ $body =~ s/^X//;
+ }
+ else {
+ $body = $self->body;
+ }
+ if ($params->{wrap} and !$self->already_wrapped) {
+ $body = wrap_comment($body);
+ }
+ return $body;
+}
+
+############
+# Mutators #
+############
+
+sub set_is_private { $_[0]->set('isprivate', $_[1]); }
+sub set_type { $_[0]->set('type', $_[1]); }
+sub set_extra_data { $_[0]->set('extra_data', $_[1]); }
+
+##############
+# Validators #
+##############
+
+sub run_create_validators {
+ my $self = shift;
+ my $params = $self->SUPER::run_create_validators(@_);
+ # Sometimes this run_create_validators is called with parameters that
+ # skip bug_id validation, so it might not exist in the resulting hash.
+ if (defined $params->{bug_id}) {
+ $params->{bug_id} = $params->{bug_id}->id;
+ }
+ return $params;
+}
+
+sub _check_extra_data {
+ my ($invocant, $extra_data, undef, $params) = @_;
+ my $type = blessed($invocant) ? $invocant->type : $params->{type};
+
+ if ($type == CMT_NORMAL) {
+ if (defined $extra_data) {
+ ThrowCodeError('comment_extra_data_not_allowed',
+ { type => $type, extra_data => $extra_data });
+ }
+ }
+ else {
+ if (!defined $extra_data) {
+ ThrowCodeError('comment_extra_data_required', { type => $type });
+ }
+ elsif ($type == CMT_ATTACHMENT_CREATED
+ or $type == CMT_ATTACHMENT_UPDATED)
+ {
+ my $attachment = Bugzilla::Attachment->check({
+ id => $extra_data });
+ $extra_data = $attachment->id;
+ }
+ else {
+ my $original = $extra_data;
+ detaint_natural($extra_data)
+ or ThrowCodeError('comment_extra_data_not_numeric',
+ { type => $type, extra_data => $original });
+ }
+ }
+
+ return $extra_data;
+}
+
+sub _check_type {
+ my ($invocant, $type) = @_;
+ $type ||= CMT_NORMAL;
+ my $original = $type;
+ detaint_natural($type)
+ or ThrowCodeError('comment_type_invalid', { type => $original });
+ return $type;
+}
+
+sub _check_bug_id {
+ my ($invocant, $bug_id) = @_;
+
+ ThrowCodeError('param_required', {function => 'Bugzilla::Comment->create',
+ param => 'bug_id'}) unless $bug_id;
+
+ my $bug;
+ if (blessed $bug_id) {
+ # We got a bug object passed in, use it
+ $bug = $bug_id;
+ $bug->check_is_visible;
+ }
+ else {
+ # We got a bug id passed in, check it and get the bug object
+ $bug = Bugzilla::Bug->check({ id => $bug_id });
+ }
+
+ # Make sure the user can edit the product
+ Bugzilla->user->can_edit_product($bug->{product_id});
+
+ # Make sure the user can comment
+ my $privs;
+ $bug->check_can_change_field('longdesc', 0, 1, \$privs)
+ || ThrowUserError('illegal_change',
+ { field => 'longdesc', privs => $privs });
+ return $bug;
+}
+
+sub _check_who {
+ my ($invocant, $who) = @_;
+ Bugzilla->login(LOGIN_REQUIRED);
+ return Bugzilla->user->id;
+}
+
+sub _check_bug_when {
+ my ($invocant, $when) = @_;
+
+ # Make sure the timestamp is defined, default to a timestamp from the db
+ if (!defined $when) {
+ $when = Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
+ }
+
+ # Make sure the timestamp parses
+ if (!datetime_from($when)) {
+ ThrowCodeError('invalid_timestamp', { timestamp => $when });
+ }
+
+ return $when;
+}
+
+sub _check_work_time {
+ my ($invocant, $value_in, $field, $params) = @_;
+
+ # Call down to Bugzilla::Object, letting it know negative
+ # values are ok
+ my $time = $invocant->check_time($value_in, $field, $params, 1);
+ my $privs;
+ $params->{bug_id}->check_can_change_field('work_time', 0, $time, \$privs)
+ || ThrowUserError('illegal_change',
+ { field => 'work_time', privs => $privs });
+ return $time;
+}
+
+sub _check_thetext {
+ my ($invocant, $thetext) = @_;
+
+ ThrowCodeError('param_required',{function => 'Bugzilla::Comment->create',
+ param => 'thetext'}) unless defined $thetext;
+
+ # Remove any trailing whitespace. Leading whitespace could be
+ # a valid part of the comment.
+ $thetext =~ s/\s*$//s;
+ $thetext =~ s/\r\n?/\n/g; # Get rid of \r.
+
+ ThrowUserError('comment_too_long') if length($thetext) > MAX_COMMENT_LENGTH;
+ return $thetext;
+}
+
+sub _check_isprivate {
+ my ($invocant, $isprivate) = @_;
+ if ($isprivate && !Bugzilla->user->is_insider) {
+ ThrowUserError('user_not_insider');
+ }
+ return $isprivate ? 1 : 0;
+}
+
+sub count {
+ my ($self) = @_;
+
+ return $self->{'count'} if defined $self->{'count'};
+
+ my $dbh = Bugzilla->dbh;
+ ($self->{'count'}) = $dbh->selectrow_array(
+ "SELECT COUNT(*)
+ FROM longdescs
+ WHERE bug_id = ?
+ AND bug_when <= ?",
+ undef, $self->bug_id, $self->creation_ts);
+
+ return --$self->{'count'};
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::Comment - A Comment for a given bug
+
+=head1 SYNOPSIS
+
+ use Bugzilla::Comment;
+
+ my $comment = Bugzilla::Comment->new($comment_id);
+ my $comments = Bugzilla::Comment->new_from_list($comment_ids);
+
+=head1 DESCRIPTION
+
+Bugzilla::Comment represents a comment attached to a bug.
+
+This implements all standard C<Bugzilla::Object> methods. See
+L<Bugzilla::Object> for more details.
+
+=head2 Accessors
+
+=over
+
+=item C<bug_id>
+
+C<int> The ID of the bug to which the comment belongs.
+
+=item C<creation_ts>
+
+C<string> The comment creation timestamp.
+
+=item C<body>
+
+C<string> The body without any special additional text.
+
+=item C<work_time>
+
+C<string> Time spent as related to this comment.
+
+=item C<is_private>
+
+C<boolean> Comment is marked as private
+
+=item C<already_wrapped>
+
+If this comment is stored in the database word-wrapped, this will be C<1>.
+C<0> otherwise.
+
+=item C<author>
+
+L<Bugzilla::User> who created the comment.
+
+=item C<count>
+
+C<int> The position this comment is located in the full list of comments for a bug starting from 0.
+
+=item C<body_full>
+
+=over
+
+=item B<Description>
+
+C<string> Body of the comment, including any special text (such as
+"this bug was marked as a duplicate of...").
+
+=item B<Params>
+
+=over
+
+=item C<is_bugmail>
+
+C<boolean>. C<1> if this comment should be formatted specifically for
+bugmail.
+
+=item C<wrap>
+
+C<boolean>. C<1> if the comment should be returned word-wrapped.
+
+=back
+
+=item B<Returns>
+
+A string, the full text of the comment as it would be displayed to an end-user.
+
+=back
+
+=back
+
+=cut
diff --git a/Bugzilla/Component.pm b/Bugzilla/Component.pm
new file mode 100644
index 0000000..1ce4e02
--- /dev/null
+++ b/Bugzilla/Component.pm
@@ -0,0 +1,652 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Component;
+use strict;
+use base qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object);
+
+use Bugzilla::Constants;
+use Bugzilla::Util;
+use Bugzilla::Error;
+use Bugzilla::User;
+use Bugzilla::FlagType;
+use Bugzilla::Series;
+
+use Scalar::Util qw(blessed);
+
+###############################
+#### Initialization ####
+###############################
+
+use constant DB_TABLE => 'components';
+# This is mostly for the editfields.cgi case where ->get_all is called.
+use constant LIST_ORDER => 'product_id, name';
+
+use constant DB_COLUMNS => qw(
+ id
+ name
+ product_id
+ initialowner
+ initialqacontact
+ description
+ isactive
+);
+
+use constant UPDATE_COLUMNS => qw(
+ name
+ initialowner
+ initialqacontact
+ description
+ isactive
+);
+
+use constant REQUIRED_FIELD_MAP => {
+ product_id => 'product',
+};
+
+use constant VALIDATORS => {
+ create_series => \&Bugzilla::Object::check_boolean,
+ product => \&_check_product,
+ initialowner => \&_check_initialowner,
+ initialqacontact => \&_check_initialqacontact,
+ description => \&_check_description,
+ initial_cc => \&_check_cc_list,
+ name => \&_check_name,
+ isactive => \&Bugzilla::Object::check_boolean,
+};
+
+use constant VALIDATOR_DEPENDENCIES => {
+ name => ['product'],
+};
+
+###############################
+
+sub new {
+ my $class = shift;
+ my $param = shift;
+ my $dbh = Bugzilla->dbh;
+
+ my $product;
+ if (ref $param and !defined $param->{id}) {
+ $product = $param->{product};
+ my $name = $param->{name};
+ if (!defined $product) {
+ ThrowCodeError('bad_arg',
+ {argument => 'product',
+ function => "${class}::new"});
+ }
+ if (!defined $name) {
+ ThrowCodeError('bad_arg',
+ {argument => 'name',
+ function => "${class}::new"});
+ }
+
+ my $condition = 'product_id = ? AND name = ?';
+ my @values = ($product->id, $name);
+ $param = { condition => $condition, values => \@values };
+ }
+
+ unshift @_, $param;
+ my $component = $class->SUPER::new(@_);
+ # Add the product object as attribute only if the component exists.
+ $component->{product} = $product if ($component && $product);
+ return $component;
+}
+
+sub create {
+ my $class = shift;
+ my $dbh = Bugzilla->dbh;
+
+ $dbh->bz_start_transaction();
+
+ $class->check_required_create_fields(@_);
+ my $params = $class->run_create_validators(@_);
+ my $cc_list = delete $params->{initial_cc};
+ my $create_series = delete $params->{create_series};
+ my $product = delete $params->{product};
+ $params->{product_id} = $product->id;
+
+ my $component = $class->insert_create_data($params);
+ $component->{product} = $product;
+
+ # We still have to fill the component_cc table.
+ $component->_update_cc_list($cc_list) if $cc_list;
+
+ # Create series for the new component.
+ $component->_create_series() if $create_series;
+
+ $dbh->bz_commit_transaction();
+ return $component;
+}
+
+sub update {
+ my $self = shift;
+ my $changes = $self->SUPER::update(@_);
+
+ # Update the component_cc table if necessary.
+ if (defined $self->{cc_ids}) {
+ my $diff = $self->_update_cc_list($self->{cc_ids});
+ $changes->{cc_list} = $diff if defined $diff;
+ }
+ return $changes;
+}
+
+sub remove_from_db {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+
+ $self->_check_if_controller(); # From ChoiceInterface
+
+ $dbh->bz_start_transaction();
+
+ # Products must have at least one component.
+ if (scalar(@{$self->product->components}) == 1) {
+ ThrowUserError('component_is_last', { comp => $self });
+ }
+
+ if ($self->bug_count) {
+ if (Bugzilla->params->{'allowbugdeletion'}) {
+ require Bugzilla::Bug;
+ foreach my $bug_id (@{$self->bug_ids}) {
+ # Note: We allow admins to delete bugs even if they can't
+ # see them, as long as they can see the product.
+ my $bug = new Bugzilla::Bug($bug_id);
+ $bug->remove_from_db();
+ }
+ } else {
+ ThrowUserError('component_has_bugs', {nb => $self->bug_count});
+ }
+ }
+ $self->SUPER::remove_from_db();
+
+ $dbh->bz_commit_transaction();
+}
+
+################################
+# Validators
+################################
+
+sub _check_name {
+ my ($invocant, $name, undef, $params) = @_;
+ my $product = blessed($invocant) ? $invocant->product : $params->{product};
+
+ $name = trim($name);
+ $name || ThrowUserError('component_blank_name');
+
+ if (length($name) > MAX_COMPONENT_SIZE) {
+ ThrowUserError('component_name_too_long', {'name' => $name});
+ }
+
+ my $component = new Bugzilla::Component({product => $product, name => $name});
+ if ($component && (!ref $invocant || $component->id != $invocant->id)) {
+ ThrowUserError('component_already_exists', { name => $component->name,
+ product => $product });
+ }
+ return $name;
+}
+
+sub _check_description {
+ my ($invocant, $description) = @_;
+
+ $description = trim($description);
+ $description || ThrowUserError('component_blank_description');
+ return $description;
+}
+
+sub _check_initialowner {
+ my ($invocant, $owner) = @_;
+
+ $owner || ThrowUserError('component_need_initialowner');
+ my $owner_id = Bugzilla::User->check($owner)->id;
+ return $owner_id;
+}
+
+sub _check_initialqacontact {
+