From d2a48e6dc832287071d3070af2d65797f2183d88 Mon Sep 17 00:00:00 2001 From: vfilippov Date: Fri, 14 May 2010 20:02:34 +0000 Subject: [PATCH] =?UTF-8?q?Bug=2040933=20-=20=D0=9D=D0=95=20=D0=94=D0=9E?= =?UTF-8?q?=20=D0=9A=D0=9E=D0=9D=D0=A6=D0=90=20=D0=BE=D1=82=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8=D0=BD=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20Bug?= =?UTF-8?q?zilla=203.6=20-=20=D0=9D=D0=98=D0=9A=D0=A3=D0=94=D0=90=20=D0=9D?= =?UTF-8?q?=D0=95=20=D0=A0=D0=90=D0=97=D0=92=D0=9E=D0=A0=D0=90=D0=A7=D0=98?= =?UTF-8?q?=D0=92=D0=90=D0=A2=D0=AC!=20:)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git-svn-id: svn://svn.office.custis.ru/3rdparty/bugzilla.org/trunk@738 6955db30-a419-402b-8a0d-67ecbb4d7f56 --- Bugzilla.pm | 196 +- Bugzilla/Attachment.pm | 735 +- Bugzilla/Auth.pm | 58 +- Bugzilla/Auth/Login/CGI.pm | 25 +- Bugzilla/Auth/Login/Cookie.pm | 27 +- Bugzilla/Auth/Login/Stack.pm | 2 +- Bugzilla/Auth/Persist/Cookie.pm | 28 +- Bugzilla/Auth/Verify/DB.pm | 48 +- Bugzilla/Auth/Verify/LDAP.pm | 37 +- Bugzilla/Auth/Verify/Stack.pm | 2 +- Bugzilla/Bug.pm | 836 +- Bugzilla/BugMail.pm | 70 +- Bugzilla/CGI.pm | 150 +- Bugzilla/Classification.pm | 2 +- Bugzilla/Comment.pm | 298 + Bugzilla/Component.pm | 6 +- Bugzilla/Config.pm | 41 +- Bugzilla/Config/Admin.pm | 2 +- Bugzilla/Config/Advanced.pm | 57 + Bugzilla/Config/Attachment.pm | 105 +- Bugzilla/Config/Auth.pm | 9 +- Bugzilla/Config/BugChange.pm | 2 +- Bugzilla/Config/BugFields.pm | 2 +- Bugzilla/Config/BugMove.pm | 2 +- Bugzilla/Config/Common.pm | 75 +- Bugzilla/Config/Core.pm | 69 +- Bugzilla/Config/DependencyGraph.pm | 2 +- Bugzilla/Config/General.pm | 83 + Bugzilla/Config/GroupSecurity.pm | 8 +- Bugzilla/Config/LDAP.pm | 2 +- Bugzilla/Config/MTA.pm | 2 +- Bugzilla/Config/PatchViewer.pm | 2 +- Bugzilla/Config/Query.pm | 9 +- Bugzilla/Config/RADIUS.pm | 2 +- Bugzilla/Config/ShadowDB.pm | 2 +- Bugzilla/Config/UserMatch.pm | 9 +- Bugzilla/Constants.pm | 83 +- Bugzilla/DB.pm | 39 +- Bugzilla/DB/Mysql.pm | 136 +- Bugzilla/DB/Oracle.pm | 104 + Bugzilla/DB/Pg.pm | 20 + Bugzilla/DB/Schema.pm | 118 +- Bugzilla/DB/Schema/Mysql.pm | 31 +- Bugzilla/DB/Schema/Oracle.pm | 28 + Bugzilla/DB/Schema/Pg.pm | 6 + Bugzilla/Error.pm | 26 +- Bugzilla/Extension.pm | 222 + Bugzilla/Field.pm | 52 +- Bugzilla/Field/Choice.pm | 16 +- Bugzilla/Flag.pm | 1333 +- Bugzilla/Group.pm | 207 +- Bugzilla/Hook.pm | 753 +- Bugzilla/Install.pm | 101 +- Bugzilla/Install/DB.pm | 362 +- Bugzilla/Install/Filesystem.pm | 79 +- Bugzilla/Install/Localconfig.pm | 38 +- Bugzilla/Install/Requirements.pm | 219 +- Bugzilla/Install/Util.pm | 167 +- Bugzilla/JobQueue.pm | 6 +- Bugzilla/Keyword.pm | 16 - Bugzilla/Mailer.pm | 7 +- Bugzilla/Migrate.pm | 1171 + Bugzilla/Migrate/Gnats.pm | 709 + Bugzilla/Object.pm | 78 +- Bugzilla/Product.pm | 131 +- Bugzilla/Search.pm | 581 +- Bugzilla/Search/Quicksearch.pm | 646 +- Bugzilla/Search/Saved.pm | 70 +- Bugzilla/Series.pm | 37 +- Bugzilla/Status.pm | 42 +- Bugzilla/Template.pm | 362 +- Bugzilla/Template/Context.pm | 104 + Bugzilla/Template/Plugin/Hook.pm | 125 +- Bugzilla/Token.pm | 4 +- Bugzilla/Update.pm | 26 +- Bugzilla/User.pm | 631 +- Bugzilla/Util.pm | 339 +- Bugzilla/Version.pm | 201 +- Bugzilla/WebService.pm | 220 +- Bugzilla/WebService/Bug.pm | 739 +- Bugzilla/WebService/Bugzilla.pm | 109 +- Bugzilla/WebService/Constants.pm | 20 +- Bugzilla/WebService/README | 18 + Bugzilla/WebService/Server.pm | 42 +- Bugzilla/WebService/Server/JSONRPC.pm | 337 + Bugzilla/WebService/Server/XMLRPC.pm | 138 +- Bugzilla/WebService/User.pm | 23 +- Bugzilla/WebService/Util.pm | 37 +- Bugzilla/Whine/Query.pm | 136 + Bugzilla/Whine/Schedule.pm | 172 + Text/TabularDisplay/Utf8.pm | 1 + attachment.cgi | 596 +- buglist.cgi | 101 +- bugzilla.dtd | 8 + chart.cgi | 110 +- checksetup.pl | 32 +- colchange.cgi | 16 +- collectstats.pl | 82 +- config.cgi | 57 +- contrib/.htaccess | 3 + contrib/bugzilla-submit/bugzilla-submit | 2 +- contrib/bzdbcopy.pl | 3 +- contrib/console.pl | 186 + contrib/cookies.txt | 1 + contrib/extension-convert.pl | 303 + contrib/fixperms.pl | 28 + docs/bugzilla.ent | 5 +- docs/en/html/Bugzilla-Guide.html | 685 +- docs/en/html/about.html | 8 +- docs/en/html/administration.html | 6 +- docs/en/html/api/Bugzilla.html | 30 +- docs/en/html/api/Bugzilla/Attachment.html | 36 +- docs/en/html/api/Bugzilla/Auth.html | 17 + docs/en/html/api/Bugzilla/CGI.html | 9 +- docs/en/html/api/Bugzilla/Comment.html | 139 + docs/en/html/api/Bugzilla/Extension.html | 536 + docs/en/html/api/Bugzilla/Flag.html | 86 +- docs/en/html/api/Bugzilla/Group.html | 52 + docs/en/html/api/Bugzilla/Hook.html | 701 +- docs/en/html/api/Bugzilla/Install.html | 9 +- .../html/api/Bugzilla/Install/Filesystem.html | 9 + .../api/Bugzilla/Install/Requirements.html | 42 +- docs/en/html/api/Bugzilla/Install/Util.html | 6 + docs/en/html/api/Bugzilla/Keyword.html | 12 - docs/en/html/api/Bugzilla/Migrate.html | 378 + docs/en/html/api/Bugzilla/Object.html | 6 + docs/en/html/api/Bugzilla/Product.html | 6 +- docs/en/html/api/Bugzilla/Search/Saved.html | 10 +- docs/en/html/api/Bugzilla/Status.html | 13 - .../api/Bugzilla/Template/Plugin/Hook.html | 10 +- docs/en/html/api/Bugzilla/User.html | 101 +- docs/en/html/api/Bugzilla/Util.html | 28 +- docs/en/html/api/Bugzilla/Version.html | 73 +- docs/en/html/api/Bugzilla/WebService.html | 224 +- docs/en/html/api/Bugzilla/WebService/Bug.html | 666 +- .../api/Bugzilla/WebService/Bugzilla.html | 85 +- .../Bugzilla/WebService/Server/JSONRPC.html | 120 + .../Bugzilla/WebService/Server/XMLRPC.html | 126 + .../en/html/api/Bugzilla/WebService/User.html | 17 +- docs/en/html/api/Bugzilla/Whine/Query.html | 126 + docs/en/html/api/Bugzilla/Whine/Schedule.html | 139 + docs/en/html/api/checksetup.html | 6 +- docs/en/html/api/contrib/console.html | 64 + .../html/api/contrib/extension-convert.html | 38 + docs/en/html/api/email_in.html | 34 +- docs/en/html/api/extensions/create.html | 38 + docs/en/html/api/index.html | 113 +- docs/en/html/api/migrate.html | 78 + docs/en/html/attachments.html | 4 +- docs/en/html/bug_page.html | 6 +- docs/en/html/bug_status_workflow.html | 4 +- docs/en/html/bugreports.html | 8 +- docs/en/html/classifications.html | 4 +- docs/en/html/cmdline-bugmail.html | 4 +- docs/en/html/cmdline.html | 4 +- docs/en/html/components.html | 4 +- docs/en/html/configuration.html | 20 +- docs/en/html/conventions.html | 4 +- docs/en/html/copyright.html | 4 +- docs/en/html/credits.html | 4 +- docs/en/html/cust-change-permissions.html | 14 +- docs/en/html/cust-skins.html | 16 +- docs/en/html/cust-templates.html | 30 +- docs/en/html/custom-fields.html | 4 +- docs/en/html/customization.html | 38 +- docs/en/html/disclaimer.html | 4 +- docs/en/html/edit-values.html | 4 +- docs/en/html/extensions.html | 161 + docs/en/html/extraconfig.html | 6 +- docs/en/html/flags-overview.html | 4 +- docs/en/html/flags.html | 4 +- docs/en/html/general-advice.html | 4 +- docs/en/html/gfdl-0.html | 4 +- docs/en/html/gfdl-1.html | 4 +- docs/en/html/gfdl-10.html | 4 +- docs/en/html/gfdl-2.html | 4 +- docs/en/html/gfdl-3.html | 4 +- docs/en/html/gfdl-4.html | 4 +- docs/en/html/gfdl-5.html | 4 +- docs/en/html/gfdl-6.html | 4 +- docs/en/html/gfdl-7.html | 4 +- docs/en/html/gfdl-8.html | 4 +- docs/en/html/gfdl-9.html | 4 +- docs/en/html/gfdl-howto.html | 6 +- docs/en/html/gfdl.html | 6 +- docs/en/html/glossary.html | 8 +- docs/en/html/groups.html | 6 +- docs/en/html/hintsandtips.html | 6 +- docs/en/html/index.html | 25 +- docs/en/html/install-perlmodules-manual.html | 4 +- docs/en/html/installation.html | 37 +- docs/en/html/installing-bugzilla.html | 18 +- docs/en/html/integration.html | 8 +- docs/en/html/keywords.html | 4 +- docs/en/html/lifecycle.html | 4 +- docs/en/html/milestones.html | 4 +- docs/en/html/modules-manual-download.html | 4 +- docs/en/html/modules-manual-instructions.html | 4 +- docs/en/html/modules-manual-optional.html | 24 +- docs/en/html/multiple-bz-dbs.html | 4 +- docs/en/html/myaccount.html | 8 +- docs/en/html/newversions.html | 6 +- docs/en/html/nonroot.html | 26 +- docs/en/html/os-specific.html | 4 +- docs/en/html/parameters.html | 37 +- docs/en/html/paranoid-security.html | 4 +- docs/en/html/patches.html | 4 +- docs/en/html/products.html | 4 +- docs/en/html/query.html | 24 +- docs/en/html/quips.html | 4 +- docs/en/html/reporting.html | 6 +- docs/en/html/sanitycheck.html | 4 +- docs/en/html/security-bugzilla.html | 4 +- docs/en/html/security-os.html | 4 +- docs/en/html/security-webserver.html | 4 +- docs/en/html/security.html | 4 +- docs/en/html/timetracking.html | 4 +- docs/en/html/trbl-dbdsponge.html | 4 +- docs/en/html/trbl-index.html | 16 +- docs/en/html/trbl-passwd-encryption.html | 6 +- docs/en/html/trbl-perlmodule.html | 4 +- docs/en/html/trbl-relogin-everyone.html | 21 +- docs/en/html/trbl-testserver.html | 4 +- docs/en/html/troubleshooting.html | 11 +- docs/en/html/upgrade.html | 6 +- docs/en/html/useradmin.html | 4 +- docs/en/html/userpreferences.html | 4 +- docs/en/html/using-intro.html | 4 +- docs/en/html/using.html | 8 +- docs/en/html/versions.html | 4 +- docs/en/html/voting.html | 4 +- docs/en/html/whining.html | 6 +- docs/en/pdf/Bugzilla-Guide.pdf | 44542 ++++++++-------- docs/en/txt/Bugzilla-Guide.txt | 296 +- docs/en/xml/Bugzilla-Guide.xml | 9 +- docs/en/xml/administration.xml | 33 +- docs/en/xml/bugzilla.ent | 5 +- docs/en/xml/customization.xml | 256 +- docs/en/xml/installation.xml | 34 +- docs/en/xml/modules.xml | 10 +- docs/en/xml/troubleshooting.xml | 29 - docs/lib/Pod/Simple/HTMLBatch/Bugzilla.pm | 2 +- duplicates.cgi | 408 +- editcomponents.cgi | 28 +- editfields.cgi | 8 +- editflagtypes.cgi | 14 +- editgroups.cgi | 175 +- editkeywords.cgi | 11 +- editparams.cgi | 4 +- editproducts.cgi | 60 +- editusers.cgi | 58 +- editvalues.cgi | 10 +- editversions.cgi | 7 +- editwhines.cgi | 117 +- editworkflow.cgi | 2 +- email_in.pl | 273 +- enter_bug.cgi | 165 +- extensions/BmpConvert/BmpConvert.pl | 38 + extensions/BmpConvert/lib/BmpConvert.pm | 37 + extensions/Example/Extension.pm | 556 + extensions/Example/lib/Auth/Login.pm | 32 + extensions/Example/lib/Auth/Verify.pm | 31 + extensions/Example/lib/Config.pm | 41 + .../{testopia/info.pl => Example/lib/Util.pm} | 21 +- extensions/Example/lib/WebService.pm | 32 + .../en/default/admin/params/example.html.tmpl | 28 + .../sanitycheck/messages-statuses.html.tmpl | 35 + .../hook/global/user-error-errors.html.tmpl | 12 + .../en/default/pages/example.html.tmpl | 32 + .../template/en/default/setup/strings.txt.pl | 24 + extensions/create.pl | 85 + .../custis/code/bug-check_bug_status.pl | 19 + .../custis/code/db_schema-abstract_schema.pl | 0 extensions/custis/code/install-update_db.pl | 1 + .../testopia/code/install-requirements.pl | 54 - extensions/testopia/testopia.pl | 75 + importxml.pl | 51 +- index.cgi | 8 - install-module.pl | 2 + js/TUI.js | 22 +- js/attachment.js | 128 + js/flag.js | 86 + js/global.js | 11 +- jsonrpc.cgi | 41 + migrate.pl | 110 + mod_perl.pl | 32 +- page.cgi | 35 +- post_bug.cgi | 146 +- process_bug.cgi | 156 +- query.cgi | 90 +- report.cgi | 42 +- reports.cgi | 15 +- request.cgi | 8 +- sanitycheck.cgi | 33 +- show_bug.cgi | 25 +- showdependencygraph.cgi | 2 +- skins/contrib/Dusk/global.css | 26 +- skins/contrib/Dusk/page.css | 4 + skins/contrib/Dusk/reports.css | 4 + skins/custom/page.css | 4 + skins/custom/reports.css | 4 + skins/standard/IE-fixes.css | 4 + skins/standard/buglist.css | 19 + skins/standard/create_attachment.css | 39 + skins/standard/duplicates.css | 45 +- skins/standard/global.css | 59 +- skins/standard/index.css | 16 +- skins/standard/page.css | 70 + skins/standard/reports.css | 89 + skins/standard/show_bug.css | 20 + summarize_time.cgi | 22 +- t/.htaccess | 3 + t/001compile.t | 140 +- t/004template.t | 1 - t/007util.t | 6 +- t/008filter.t | 9 +- t/012throwables.t | 6 +- t/Support/Files.pm | 30 +- .../account/auth/login-small.html.tmpl | 11 +- .../en/default/account/auth/login.html.tmpl | 20 +- .../default/account/prefs/account.html.tmpl | 5 +- .../en/default/account/prefs/email.html.tmpl | 4 +- .../account/prefs/saved-searches.html.tmpl | 22 +- .../account/profile-activity.html.tmpl | 2 +- template/en/default/admin/admin.html.tmpl | 34 +- .../admin/classifications/add.html.tmpl | 3 +- .../admin/classifications/del.html.tmpl | 3 +- .../admin/classifications/edit.html.tmpl | 3 +- .../admin/classifications/footer.html.tmpl | 24 + .../classifications/reclassify.html.tmpl | 3 +- .../admin/components/confirm-delete.html.tmpl | 4 +- .../admin/custom_fields/edit.html.tmpl | 62 +- .../admin/custom_fields/list.html.tmpl | 3 +- .../fieldvalues/confirm-delete.html.tmpl | 8 +- .../default/admin/fieldvalues/edit.html.tmpl | 13 +- .../default/admin/fieldvalues/list.html.tmpl | 13 +- .../admin/flag-type/confirm-delete.html.tmpl | 2 +- .../en/default/admin/groups/delete.html.tmpl | 124 +- .../en/default/admin/groups/edit.html.tmpl | 6 +- .../en/default/admin/groups/list.html.tmpl | 30 +- .../default/admin/params/advanced.html.tmpl | 50 + .../default/admin/params/attachment.html.tmpl | 5 +- .../en/default/admin/params/auth.html.tmpl | 5 - .../default/admin/params/bugfields.html.tmpl | 2 +- .../en/default/admin/params/common.html.tmpl | 26 +- .../en/default/admin/params/core.html.tmpl | 77 +- .../default/admin/params/editparams.html.tmpl | 3 +- .../en/default/admin/params/general.html.tmpl | 86 + .../admin/params/groupsecurity.html.tmpl | 6 - .../en/default/admin/params/index.html.tmpl | 2 +- .../en/default/admin/params/query.html.tmpl | 10 +- .../default/admin/params/usermatch.html.tmpl | 6 - .../admin/products/confirm-delete.html.tmpl | 11 +- .../default/admin/products/create.html.tmpl | 7 +- .../admin/products/edit-common.html.tmpl | 87 +- .../en/default/admin/products/edit.html.tmpl | 1 + .../products/groupcontrol/updated.html.tmpl | 2 +- .../en/default/admin/products/list.html.tmpl | 15 +- .../default/admin/products/updated.html.tmpl | 49 +- .../admin/sanitycheck/messages.html.tmpl | 11 + template/en/default/admin/table.html.tmpl | 14 +- .../en/default/admin/users/list.html.tmpl | 10 +- .../default/admin/workflow/comment.html.tmpl | 4 +- .../en/default/admin/workflow/edit.html.tmpl | 10 +- .../en/default/attachment/create.html.tmpl | 27 +- .../en/default/attachment/created.html.tmpl | 16 +- .../en/default/attachment/diff-file.html.tmpl | 42 +- .../default/attachment/diff-footer.html.tmpl | 3 + .../default/attachment/diff-header.html.tmpl | 2 + template/en/default/attachment/edit.html.tmpl | 308 +- template/en/default/attachment/list.html.tmpl | 12 +- .../attachment/show-multiple.html.tmpl | 3 +- .../en/default/attachment/updated.html.tmpl | 20 +- .../en/default/bug/activity/show.html.tmpl | 4 +- .../en/default/bug/activity/table.html.tmpl | 72 +- template/en/default/bug/comments.html.tmpl | 77 +- .../en/default/bug/create/create.html.tmpl | 37 +- .../en/default/bug/create/created.html.tmpl | 14 +- .../en/default/bug/dependency-tree.html.tmpl | 14 +- template/en/default/bug/edit.html.tmpl | 551 +- template/en/default/bug/field.html.tmpl | 27 +- .../en/default/bug/format_comment.txt.tmpl | 46 +- template/en/default/bug/knob.html.tmpl | 159 +- template/en/default/bug/navigate.html.tmpl | 25 +- .../en/default/bug/process/bugmail.html.tmpl | 42 +- .../en/default/bug/process/header.html.tmpl | 8 +- .../en/default/bug/process/midair.html.tmpl | 10 +- .../en/default/bug/process/results.html.tmpl | 2 - .../bug/process/verify-new-product.html.tmpl | 14 +- template/en/default/bug/show-header.html.tmpl | 52 + .../en/default/bug/show-multiple.html.tmpl | 16 +- template/en/default/bug/show.html.tmpl | 26 +- template/en/default/bug/show.xml.tmpl | 66 +- .../en/default/bug/summarize-time.html.tmpl | 8 +- .../default/bug/votes/list-for-user.html.tmpl | 2 +- template/en/default/config.rdf.tmpl | 70 +- template/en/default/email/lockout.txt.tmpl | 39 + .../en/default/email/newchangedmail.txt.tmpl | 5 +- template/en/default/email/whine.txt.tmpl | 12 +- template/en/default/extensions/config.pm.tmpl | 41 + .../en/default/extensions/extension.pm.tmpl | 46 + .../default/extensions/hook-readme.txt.tmpl | 27 + .../en/default/extensions/license.txt.tmpl | 47 + .../default/extensions/name-readme.txt.tmpl | 38 + template/en/default/extensions/util.pm.tmpl | 42 + template/en/default/filterexceptions.pl | 36 +- template/en/default/flag/list.html.tmpl | 177 +- .../default/global/choose-product.html.tmpl | 8 +- .../en/default/global/code-error.html.tmpl | 195 +- .../en/default/global/common-links.html.tmpl | 18 +- .../global/confirm-user-match.html.tmpl | 29 +- .../en/default/global/field-descs.none.tmpl | 205 +- template/en/default/global/header.html.tmpl | 52 +- template/en/default/global/message.txt.tmpl | 2 - template/en/default/global/messages.html.tmpl | 121 +- .../default/global/per-bug-queries.html.tmpl | 2 +- .../en/default/global/setting-descs.none.tmpl | 4 +- .../default/global/site-navigation.html.tmpl | 32 +- template/en/default/global/tabs.html.tmpl | 8 +- template/en/default/global/textarea.html.tmpl | 2 + .../en/default/global/useful-links.html.tmpl | 8 +- .../en/default/global/user-error.html.tmpl | 358 +- .../en/default/global/userselect.html.tmpl | 2 + template/en/default/index.html.tmpl | 29 +- .../en/default/list/change-columns.html.tmpl | 6 +- .../en/default/list/edit-multiple.html.tmpl | 28 +- template/en/default/list/list.atom.tmpl | 12 +- template/en/default/list/list.csv.tmpl | 4 +- template/en/default/list/list.html.tmpl | 61 +- template/en/default/list/list.ics.tmpl | 1 + template/en/default/list/table.html.tmpl | 131 +- .../en/default/pages/bug-writing.html.tmpl | 4 +- template/en/default/pages/fields.html.tmpl | 99 +- .../en/default/pages/quicksearch.html.tmpl | 381 +- .../en/default/pages/release-notes.html.tmpl | 964 +- template/en/default/reports/chart.png.tmpl | 2 +- .../en/default/reports/components.html.tmpl | 65 +- .../en/default/reports/create-chart.html.tmpl | 24 +- .../default/reports/delete-series.html.tmpl | 59 + .../reports/duplicates-simple.html.tmpl | 14 +- .../reports/duplicates-table.html.tmpl | 167 +- .../en/default/reports/duplicates.html.tmpl | 70 +- .../en/default/reports/edit-series.html.tmpl | 6 +- template/en/default/reports/menu.html.tmpl | 51 +- .../en/default/reports/report-bar.png.tmpl | 24 +- .../en/default/reports/report-line.png.tmpl | 24 +- .../en/default/reports/report-pie.png.tmpl | 12 +- .../en/default/reports/report-table.csv.tmpl | 8 +- .../en/default/reports/report-table.html.tmpl | 10 +- template/en/default/request/email.txt.tmpl | 19 +- template/en/default/request/queue.html.tmpl | 68 +- .../default/search/boolean-charts.html.tmpl | 3 +- template/en/default/search/form.html.tmpl | 59 +- .../search/search-report-select.html.tmpl | 6 +- .../default/search/search-specific.html.tmpl | 2 +- template/en/default/search/tabs.html.tmpl | 2 +- template/en/default/setup/strings.txt.pl | 31 +- template/en/default/welcome-admin.html.tmpl | 9 +- template/en/default/whine/mail.html.tmpl | 10 +- template/en/default/whine/mail.txt.tmpl | 10 +- template/en/default/whine/schedule.html.tmpl | 10 + testserver.pl | 16 +- token.cgi | 20 +- tr_xmlrpc.cgi | 13 +- userprefs.cgi | 87 +- votes.cgi | 5 +- whine.pl | 20 +- xmlrpc.cgi | 13 +- 468 files changed, 43862 insertions(+), 34068 deletions(-) create mode 100644 Bugzilla/Comment.pm create mode 100644 Bugzilla/Config/Advanced.pm create mode 100644 Bugzilla/Config/General.pm create mode 100644 Bugzilla/Extension.pm create mode 100644 Bugzilla/Migrate.pm create mode 100644 Bugzilla/Migrate/Gnats.pm create mode 100644 Bugzilla/Template/Context.pm create mode 100644 Bugzilla/WebService/README create mode 100644 Bugzilla/WebService/Server/JSONRPC.pm create mode 100644 Bugzilla/Whine/Query.pm create mode 100644 Bugzilla/Whine/Schedule.pm create mode 100644 contrib/.htaccess create mode 100644 contrib/console.pl create mode 100644 contrib/cookies.txt create mode 100644 contrib/extension-convert.pl create mode 100644 contrib/fixperms.pl create mode 100644 docs/en/html/api/Bugzilla/Comment.html create mode 100644 docs/en/html/api/Bugzilla/Extension.html create mode 100644 docs/en/html/api/Bugzilla/Migrate.html create mode 100644 docs/en/html/api/Bugzilla/WebService/Server/JSONRPC.html create mode 100644 docs/en/html/api/Bugzilla/WebService/Server/XMLRPC.html create mode 100644 docs/en/html/api/Bugzilla/Whine/Query.html create mode 100644 docs/en/html/api/Bugzilla/Whine/Schedule.html create mode 100644 docs/en/html/api/contrib/console.html create mode 100644 docs/en/html/api/contrib/extension-convert.html create mode 100644 docs/en/html/api/extensions/create.html create mode 100644 docs/en/html/api/migrate.html create mode 100644 docs/en/html/extensions.html create mode 100644 extensions/BmpConvert/BmpConvert.pl create mode 100644 extensions/BmpConvert/lib/BmpConvert.pm create mode 100644 extensions/Example/Extension.pm create mode 100644 extensions/Example/lib/Auth/Login.pm create mode 100644 extensions/Example/lib/Auth/Verify.pm create mode 100644 extensions/Example/lib/Config.pm rename extensions/{testopia/info.pl => Example/lib/Util.pm} (54%) create mode 100644 extensions/Example/lib/WebService.pm create mode 100644 extensions/Example/template/en/default/admin/params/example.html.tmpl create mode 100644 extensions/Example/template/en/default/hook/admin/sanitycheck/messages-statuses.html.tmpl create mode 100644 extensions/Example/template/en/default/hook/global/user-error-errors.html.tmpl create mode 100644 extensions/Example/template/en/default/pages/example.html.tmpl create mode 100644 extensions/Example/template/en/default/setup/strings.txt.pl create mode 100644 extensions/create.pl create mode 100644 extensions/custis/code/bug-check_bug_status.pl mode change 100755 => 100644 extensions/custis/code/db_schema-abstract_schema.pl delete mode 100644 extensions/testopia/code/install-requirements.pl create mode 100644 extensions/testopia/testopia.pl create mode 100644 js/flag.js create mode 100755 jsonrpc.cgi create mode 100644 migrate.pl create mode 100644 skins/contrib/Dusk/page.css create mode 100644 skins/contrib/Dusk/reports.css create mode 100644 skins/custom/page.css create mode 100644 skins/custom/reports.css create mode 100644 skins/standard/page.css create mode 100644 skins/standard/reports.css create mode 100644 t/.htaccess create mode 100644 template/en/default/admin/classifications/footer.html.tmpl create mode 100644 template/en/default/admin/params/advanced.html.tmpl create mode 100644 template/en/default/admin/params/general.html.tmpl create mode 100644 template/en/default/bug/show-header.html.tmpl create mode 100644 template/en/default/email/lockout.txt.tmpl create mode 100644 template/en/default/extensions/config.pm.tmpl create mode 100644 template/en/default/extensions/extension.pm.tmpl create mode 100644 template/en/default/extensions/hook-readme.txt.tmpl create mode 100644 template/en/default/extensions/license.txt.tmpl create mode 100644 template/en/default/extensions/name-readme.txt.tmpl create mode 100644 template/en/default/extensions/util.pm.tmpl create mode 100644 template/en/default/reports/delete-series.html.tmpl diff --git a/Bugzilla.pm b/Bugzilla.pm index fbe6f6e48..cc8d04d8c 100644 --- a/Bugzilla.pm +++ b/Bugzilla.pm @@ -32,9 +32,11 @@ 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::JobQueue; +use Bugzilla::Install::Requirements qw(OPTIONAL_MODULES); +use Bugzilla::Install::Util; use Bugzilla::Template; use Bugzilla::User; use Bugzilla::Error; @@ -78,9 +80,6 @@ BEGIN }; } -# This creates the request cache for non-mod_perl installations. -our $_request_cache = {}; - ##################################################################### # Constants ##################################################################### @@ -89,6 +88,7 @@ our $_request_cache = {}; use constant SHUTDOWNHTML_EXEMPT => [ 'editparams.cgi', 'checksetup.pl', + 'migrate.pl', 'recode.pl', ]; @@ -119,7 +119,11 @@ my $re_encoded_word = qr{ my $re_especials = qr{$re_encoded_word}xo; # >>> -*Encode::MIME::Header::encode = sub($$;$) { +undef &Encode::MIME::Header::encode; + +*Encode::MIME::Header::encode = *encode_mime_header; + +sub encode_mime_header($$;$) { my ( $obj, $str, $chk ) = @_; my @line = (); for my $line ( split /\r\n|[\r\n]/o, $str ) { @@ -151,6 +155,7 @@ my $re_especials = qr{$re_encoded_word}xo; $_[1] = '' if $chk; return join( "\n", @line ); } + } ##################################################################### @@ -163,15 +168,20 @@ my $re_especials = qr{$re_encoded_word}xo; sub init_page { (binmode STDOUT, ':utf8') if Bugzilla->params->{'utf8'}; - if (${^TAINT}) { - # Some environment variables are not taint safe - delete @::ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'}; - # Some modules throw undefined errors (notably File::Spec::Win32) if - # PATH is undefined. - $ENV{'PATH'} = ''; + # Some environment variables are not taint safe + delete @::ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'}; + # Some modules throw undefined errors (notably File::Spec::Win32) if + # PATH is undefined. + $ENV{'PATH'} = ''; } + # Because this function is run live from perl "use" commands of + # other scripts, we're skipping the rest of this function if we get here + # during a perl syntax check (perl -c, like we do during the + # 001compile.t test). + return if $^C; + # IIS prints out warnings to the webpage, so ignore them, or log them # to a file if the file exists. if ($ENV{SERVER_SOFTWARE} && $ENV{SERVER_SOFTWARE} =~ /microsoft-iis/i) { @@ -186,18 +196,18 @@ sub init_page { }; } + # Because of attachment_base, attachment.cgi handles this itself. + if (basename($0) ne 'attachment.cgi') { + do_ssl_redirect_if_required(); + } + # If Bugzilla is shut down, do not allow anything to run, just display a # message to the user about the downtime and log out. Scripts listed in # SHUTDOWNHTML_EXEMPT are exempt from this message. # - # Because this is code which is run live from perl "use" commands of other - # scripts, we're skipping this part if we get here during a perl syntax - # check -- runtests.pl compiles scripts without running them, so we - # need to make sure that this check doesn't apply to 'perl -c' calls. - # # This code must go here. It cannot go anywhere in Bugzilla::CGI, because # it uses Template, and that causes various dependency loops. - if (!$^C && Bugzilla->params->{"shutdownhtml"} + if (Bugzilla->params->{"shutdownhtml"} && lsearch(SHUTDOWNHTML_EXEMPT, basename($0)) == -1) { # Allow non-cgi scripts to exit silently (without displaying any @@ -244,8 +254,6 @@ sub init_page { } } -init_page() if !$ENV{MOD_PERL}; - ##################################################################### # Subroutines and Methods ##################################################################### @@ -266,12 +274,83 @@ sub template_inner { return $class->request_cache->{"template_inner_$lang"}; } +our $extension_packages; +sub extensions { + my ($class) = @_; + my $cache = $class->request_cache; + if (!$cache->{extensions}) { + # Under mod_perl, mod_perl.pl populates $extension_packages for us. + if (!$extension_packages) { + $extension_packages = Bugzilla::Extension->load_all(); + } + my @extensions; + foreach my $package (@$extension_packages) { + my $extension = $package->new(); + if ($extension->enabled) { + push(@extensions, $extension); + } + } + $cache->{extensions} = \@extensions; + } + return $cache->{extensions}; +} + +sub feature { + my ($class, $feature) = @_; + my $cache = $class->request_cache; + return $cache->{feature}->{$feature} + if exists $cache->{feature}->{$feature}; + + my $feature_map = $cache->{feature_map}; + if (!$feature_map) { + foreach my $package (@{ OPTIONAL_MODULES() }) { + foreach my $f (@{ $package->{feature} }) { + $feature_map->{$f} ||= []; + push(@{ $feature_map->{$f} }, $package->{module}); + } + } + $cache->{feature_map} = $feature_map; + } + + if (!$feature_map->{$feature}) { + ThrowCodeError('invalid_feature', { feature => $feature }); + } + + my $success = 1; + foreach my $module (@{ $feature_map->{$feature} }) { + # We can't use a string eval and "use" here (it kills Template-Toolkit, + # see https://rt.cpan.org/Public/Bug/Display.html?id=47929), so we have + # to do a block eval. + $module =~ s{::}{/}g; + $module .= ".pm"; + eval { require $module; 1; } or $success = 0; + } + $cache->{feature}->{$feature} = $success; + return $success; +} + sub cgi { my $class = shift; $class->request_cache->{cgi} ||= new Bugzilla::CGI(); return $class->request_cache->{cgi}; } +sub input_params { + my ($class, $params) = @_; + my $cache = $class->request_cache; + # This is how the WebService and other places set input_params. + if (defined $params) { + $cache->{input_params} = $params; + } + return $cache->{input_params} if defined $cache->{input_params}; + + # Making this scalar makes it a tied hash to the internals of $cgi, + # so if a variable is changed, then it actually changes the $cgi object + # as well. + $cache->{input_params} = $class->cgi->Vars; + return $cache->{input_params}; +} + sub localconfig { my $class = shift; $class->request_cache->{localconfig} ||= read_localconfig(); @@ -318,6 +397,7 @@ sub login { 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; } @@ -361,14 +441,6 @@ sub login { $class->set_user($authenticated_user); } - # We run after the login has completed since - # some of the checks in ssl_require_redirect - # look for Bugzilla->user->id to determine - # if redirection is required. - if (i_am_cgi() && ssl_require_redirect()) { - $class->cgi->require_https($class->params->{'sslbase'}); - } - return $class->user; } @@ -408,6 +480,7 @@ sub logout_request { sub job_queue { my $class = shift; + require Bugzilla::JobQueue; $class->request_cache->{job_queue} ||= Bugzilla::JobQueue->new(); return $class->request_cache->{job_queue}; } @@ -455,6 +528,15 @@ sub error_mode { || (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) { @@ -464,9 +546,12 @@ sub usage_mode { elsif ($newval == USAGE_MODE_CMDLINE) { $class->error_mode(ERROR_MODE_DIE); } - elsif ($newval == USAGE_MODE_WEBSERVICE) { + elsif ($newval == USAGE_MODE_XMLRPC) { $class->error_mode(ERROR_MODE_DIE_SOAP_FAULT); } + elsif ($newval == USAGE_MODE_JSON) { + $class->error_mode(ERROR_MODE_JSON_RPC); + } elsif ($newval == USAGE_MODE_EMAIL) { $class->error_mode(ERROR_MODE_DIE); } @@ -541,7 +626,7 @@ 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 }); + Bugzilla::Field->match({ custom => 1, obsolete => 0 }); } return @{$class->request_cache->{active_custom_fields}}; } @@ -555,12 +640,6 @@ sub has_flags { return $class->request_cache->{has_flags}; } -sub hook_args { - my ($class, $args) = @_; - $class->request_cache->{hook_args} = $args if $args; - return $class->request_cache->{hook_args}; -} - sub local_timezone { my $class = shift; @@ -571,10 +650,20 @@ sub local_timezone { return $class->request_cache->{local_timezone}; } +# This creates the request cache for non-mod_perl installations. +# This is identical to Install::Util::_cache so that things loaded +# into Install::Util::_cache during installation can be read out +# of request_cache later in installation. +our $_request_cache = $Bugzilla::Install::Util::_cache; + sub request_cache { if ($ENV{MOD_PERL}) { require Apache2::RequestUtil; - return Apache2::RequestUtil->request->pnotes(); + # Sometimes (for example, during mod_perl.pl), the request + # object isn't available, and we should use $_request_cache instead. + my $request = eval { Apache2::RequestUtil->request }; + return $_request_cache if !$request; + return $request->pnotes(); } return $_request_cache ||= {}; } @@ -599,6 +688,8 @@ sub END { _cleanup() unless $ENV{MOD_PERL}; } +init_page() if !$ENV{MOD_PERL}; + 1; __END__ @@ -682,6 +773,26 @@ The current C object. Note that modules should B 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 + +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 currently represents). + +This should be used instead of L 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 When C represents the CGI parameters, any +parameter specified more than once (like C) 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 appear multiple times, +if it only I appear once, then it will be a scalar in C, +not an arrayref. + =item C C if there is no currently logged in user or if the login code has not @@ -766,10 +877,11 @@ usage mode changes. =item C Call either Cusage_mode(Bugzilla::Constants::USAGE_MODE_CMDLINE)> -or Cusage_mode(Bugzilla::Constants::USAGE_MODE_WEBSERVICE)> near the +or Cusage_mode(Bugzilla::Constants::USAGE_MODE_XMLRPC)> near the beginning of your script to change this flag's default of C and to indicate that Bugzilla is being called in a non-interactive manner. + This influences error handling because on usage mode changes, C calls Cerror_mode> to set an error mode which makes sense for the usage mode. @@ -813,11 +925,6 @@ The current Parameters of Bugzilla, as a hashref. If C does not exist, then we return an empty hashref. If C is unreadable or is not valid perl, we C. -=item C - -If you are running inside a code hook (see L) this -is how you get the arguments passed to the hook. - =item C Returns the local timezone of the Bugzilla installation, @@ -830,4 +937,9 @@ Returns a L that you can use for queueing jobs. Will throw an error if job queueing is not correctly configured on this Bugzilla installation. +=item C + +Tells you whether or not a specific feature is enabled. For names +of features, see C in C. + =back diff --git a/Bugzilla/Attachment.pm b/Bugzilla/Attachment.pm index c8022bc99..c10eb5e9e 100644 --- a/Bugzilla/Attachment.pm +++ b/Bugzilla/Attachment.pm @@ -57,12 +57,12 @@ use Bugzilla::Flag; use Bugzilla::User; use Bugzilla::Util; use Bugzilla::Field; +use Bugzilla::Hook; + use LWP::MediaTypes; use base qw(Bugzilla::Object); -use Encode; - ############################### #### Initialization #### ############################### @@ -89,6 +89,38 @@ sub DB_COLUMNS { $dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i') . ' AS creation_ts'; } +use constant REQUIRED_CREATE_FIELDS => qw( + bug + data + description + filename + mimetype +); + +use constant UPDATE_COLUMNS => qw( + description + filename + isobsolete + ispatch + isprivate + mimetype +); + +use constant VALIDATORS => { + bug => \&_check_bug, + description => \&_check_description, + ispatch => \&Bugzilla::Object::check_boolean, + isprivate => \&_check_is_private, + isurl => \&_check_is_url, + mimetype => \&_check_content_type, + store_in_file => \&_check_store_in_file, +}; + +use constant UPDATE_VALIDATORS => { + filename => \&_check_filename, + isobsolete => \&Bugzilla::Object::check_boolean, +}; + ############################### #### Accessors ###### ############################### @@ -126,7 +158,7 @@ sub bug { my $self = shift; require Bugzilla::Bug; - $self->{bug} = Bugzilla::Bug->new($self->bug_id); + $self->{bug} ||= Bugzilla::Bug->new($self->bug_id); return $self->{bug}; } @@ -396,6 +428,13 @@ sub datasize { 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 @@ -408,9 +447,9 @@ flags that have been set on the attachment sub flags { my $self = shift; - return $self->{flags} if exists $self->{flags}; - $self->{flags} = Bugzilla::Flag->match({ 'attach_id' => $self->id }); + # Don't cache it as it must be in sync with ->flag_types. + $self->{flags} = [map { @{$_->{flags}} } @{$self->flag_types}]; return $self->{flags}; } @@ -434,7 +473,7 @@ sub flag_types { component_id => $self->bug->component_id, attach_id => $self->id }; - $self->{flag_types} = Bugzilla::Flag::_flag_types($vars); + $self->{flag_types} = Bugzilla::Flag->_flag_types($vars); return $self->{flag_types}; } @@ -442,33 +481,136 @@ sub flag_types { #### Validators ###### ############################### -# Instance methods; no POD documentation here yet because the only ones so far -# are private. +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 _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; +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 _validate_filename { - my ($throw_error) = @_; - my $cgi = Bugzilla->cgi; - defined $cgi->upload('data') - || ($cgi->param('text_attachment') !~ /^\s*$/so) - || ($throw_error ? ThrowUserError("file_not_specified") : return 0); +sub set_flags { + my ($self, $flags, $new_flags) = @_; - my $filename = $cgi->upload('data') || $cgi->param('filename'); - $filename = $cgi->param('description') - if !$filename && $cgi->param('text_attachment') !~ /^\s*$/so; - if (Bugzilla->params->{utf8}) - { - # CGI::upload() will probably return non-UTF8 string, so set UTF8 flag on - # utf8::decode() or Encode::_utf8_on() does not work on tainted values... - $filename = trick_taint_copy($filename); - Encode::_utf8_on($filename); + 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; + ($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 _legal_content_type { + my ($content_type) = @_; + my $legal_types = join('|', LEGAL_CONTENT_TYPES); + return $content_type !~ /^($legal_types)\/.+$/; +} + +sub _check_content_type { + my ($invocant, $content_type) = @_; + + $content_type = 'text/plain' if (ref $invocant && ($invocant->isurl || $invocant->ispatch)); + if (!_legal_content_type($content_type)) { + ThrowUserError("invalid_content_type", { contenttype => $content_type }); } + trick_taint($content_type); + + return $content_type; +} + +sub _check_data { + my ($invocant, $params) = @_; + + my $data; + if ($params->{isurl}) { + $data = $params->{data}; + ($data && $data =~ m#^(http|https|ftp)://\S+#) + || ThrowUserError('attachment_illegal_url', { url => $data }); + + $params->{mimetype} = 'text/plain'; + $params->{ispatch} = 0; + $params->{store_in_file} = 0; + } + else { + if ($params->{store_in_file} || !ref $params->{data}) { + # If it's a filehandle, just store it, not the content of the file + # itself as the file may be quite large. If it's not a filehandle, + # it already contains the content of the file. + $data = $params->{data}; + } + else { + # The file will be stored in the DB. We need the content of the file. + local $/; + my $fh = $params->{data}; + $data = <$fh>; + } + } + Bugzilla::Hook::process('attachment_process_data', { data => \$data, + attributes => $params }); + + # Do not validate the size if we have a filehandle. It will be checked later. + return $data if ref $data; + + $data || ThrowUserError('zero_length_file'); + # Make sure the attachment does not exceed the maximum permitted size. + my $len = length($data); + my $max_size = $params->{store_in_file} ? Bugzilla->params->{'maxlocalattachment'} * 1048576 + : Bugzilla->params->{'maxattachmentsize'} * 1024; + if ($len > $max_size) { + my $vars = { filesize => sprintf("%.0f", $len/1024) }; + if ($params->{ispatch}) { + ThrowUserError('patch_too_large', $vars); + } + elsif ($params->{store_in_file}) { + ThrowUserError('local_file_too_large'); + } + else { + 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, $is_url) = @_; + + $is_url = $invocant->isurl if ref $invocant; + # No file is attached, so it has no name. + return '' if $is_url; + + $filename = trim($filename); + $filename || 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 @@ -480,70 +622,39 @@ sub _validate_filename { # Truncate the filename to 100 characters, counting from the end of the # string to make sure we keep the filename extension. $filename = substr($filename, -100, 100); + trick_taint($filename); return $filename; } -sub _validate_data { - my ($throw_error, $hr_vars) = @_; - my $cgi = Bugzilla->cgi; +sub _check_is_private { + my ($invocant, $is_private) = @_; - my $fh; - # Skip uploading into a local variable if the user wants to upload huge - # attachments into local files. - if (!$cgi->param('bigfile')) { - $fh = $cgi->upload('data'); + $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'); } - my $data; + return $is_private; +} - # We could get away with reading only as much as required, except that then - # we wouldn't have a size to print to the error handler below. - if (!$cgi->param('bigfile')) { - # enable 'slurp' mode - local $/; - $data = <$fh>; +sub _check_is_url { + my ($invocant, $is_url) = @_; + + if ($is_url && !Bugzilla->params->{'allow_attach_url'}) { + ThrowCodeError('attachment_url_disabled'); } + return $is_url ? 1 : 0; +} - $data - || ($cgi->param('bigfile')) - || ($cgi->param('text_attachment') !~ /^\s*$/so) - || ($throw_error ? ThrowUserError("zero_length_file") : return 0); +sub _check_store_in_file { + my ($invocant, $store_in_file) = @_; - if (!$data && $cgi->param('text_attachment') !~ /^\s*$/so) - { - $data = $cgi->param('text_attachment'); + if ($store_in_file && !Bugzilla->params->{'maxlocalattachment'}) { + ThrowCodeError('attachment_local_storage_disabled'); } - - # Windows screenshots are usually uncompressed BMP files which - # makes for a quick way to eat up disk space. Let's compress them. - # We do this before we check the size since the uncompressed version - # could easily be greater than maxattachmentsize. - if (Bugzilla->params->{'convert_uncompressed_images'} - && $cgi->param('contenttype') eq 'image/bmp') { - require Image::Magick; - my $img = Image::Magick->new(magick=>'bmp'); - $img->BlobToImage($data); - $img->set(magick=>'png'); - my $imgdata = $img->ImageToBlob(); - $data = $imgdata; - $cgi->param('contenttype', 'image/png'); - $hr_vars->{'convertedbmp'} = 1; - } - - # Make sure the attachment does not exceed the maximum permitted size - my $maxsize = Bugzilla->params->{'maxattachmentsize'} * 1024; # Convert from K - my $len = $data ? length($data) : 0; - if ($maxsize && $len > $maxsize) { - my $vars = { filesize => sprintf("%.0f", $len/1024) }; - if ($cgi->param('ispatch')) { - $throw_error ? ThrowUserError("patch_too_large", $vars) : return 0; - } - else { - $throw_error ? ThrowUserError("file_too_large", $vars) : return 0; - } - } - - return $data || ''; + return $store_in_file ? 1 : 0; } =pod @@ -606,128 +717,6 @@ sub get_attachments_by_bug { =pod -=item C - -Description: validates the "patch" flag passed in by CGI. - -Returns: 1 on success. - -=cut - -sub validate_is_patch { - my ($class, $throw_error) = @_; - my $cgi = Bugzilla->cgi; - - # Set the ispatch flag to zero if it is undefined, since the UI uses - # an HTML checkbox to represent this flag, and unchecked HTML checkboxes - # do not get sent in HTML requests. - $cgi->param('ispatch', $cgi->param('ispatch') ? 1 : 0); - - # Set the content type to text/plain if the attachment is a patch. - $cgi->param('contenttype', 'text/plain') if $cgi->param('ispatch'); - - return 1; -} - -=pod - -=item C - -Description: validates the description passed in by CGI. - -Returns: 1 on success. - -=cut - -sub validate_description { - my ($class, $throw_error) = @_; - my $cgi = Bugzilla->cgi; - - $cgi->param('description') - || ($throw_error ? ThrowUserError("missing_attachment_description") : return 0); - - return 1; -} - -=pod - -=item C - -Description: validates the content type passed in by CGI. - -Returns: 1 on success. - -=cut - -sub valid_content_type { $_[0] =~ /^(application|audio|image|message|model|multipart|text|video)\/.+$/ } - -my $lwp_read_mime_types; -sub validate_content_type { - my ($class, $throw_error) = @_; - my $cgi = Bugzilla->cgi; - - if (!defined $cgi->param('contenttypemethod')) { - $throw_error ? ThrowUserError("missing_content_type_method") : return 0; - } - elsif ($cgi->param('contenttypemethod') eq 'autodetect') { - my $contenttype; - if ($cgi->param('data')) - { - $contenttype = $cgi->uploadInfo($cgi->param('data'))->{'Content-Type'}; - if (!valid_content_type($contenttype) && Bugzilla->params->{mime_types_file}) - { - if (!$lwp_read_mime_types) - { - LWP::MediaTypes::read_media_types(Bugzilla->params->{mime_types_file}); - $lwp_read_mime_types = 1; - } - my $file = $cgi->param('data'); - $contenttype = LWP::MediaTypes::guess_media_type("$file"); - } - if (!valid_content_type($contenttype)) - { - $contenttype = 'application/octet-stream'; - } - } - else - { - $contenttype = 'text/plain'; - } - # The user asked us to auto-detect the content type, so use the type - # specified in the HTTP request headers. - if ( !$contenttype ) { - $throw_error ? ThrowUserError("missing_content_type") : return 0; - } - $cgi->param('contenttype', $contenttype); - } - elsif ($cgi->param('contenttypemethod') eq 'list') { - # The user selected a content type from the list, so use their - # selection. - $cgi->param('contenttype', $cgi->param('contenttypeselection')); - } - elsif ($cgi->param('contenttypemethod') eq 'manual') { - # The user entered a content type manually, so use their entry. - $cgi->param('contenttype', $cgi->param('contenttypeentry')); - } - else { - $throw_error ? - ThrowCodeError("illegal_content_type_method", - { contenttypemethod => $cgi->param('contenttypemethod') }) : - return 0; - } - - if (!valid_content_type($cgi->param('contenttype'))) { - $throw_error ? - ThrowUserError("invalid_content_type", - { contenttype => $cgi->param('contenttype') }) : - return 0; - } - - return 1; -} - -=pod - =item C Description: validates if the user is allowed to view and edit the attachment. @@ -738,7 +727,7 @@ Description: validates if the user is allowed to view and edit the attachment. Params: $attachment - the attachment object being edited. $product_id - the product ID the attachment belongs to. -Returns: 1 on success. Else an error is thrown. +Returns: 1 on success, 0 otherwise. =cut @@ -747,12 +736,9 @@ sub validate_can_edit { my $user = Bugzilla->user; # The submitter can edit their attachments. - return 1 if ($attachment->attacher->id == $user->id - || ((!$attachment->isprivate || $user->is_insider) - && $user->in_group('editbugs', $product_id))); - - # If we come here, then this attachment cannot be seen by the user. - ThrowUserError('illegal_attachment_edit', { attach_id => $attachment->id }); + return ($attachment->attacher->id == $user->id + || ((!$attachment->isprivate || $user->is_insider) + && $user->in_group('editbugs', $product_id))) ? 1 : 0; } =item C @@ -769,14 +755,13 @@ Returns: 1 on success. Else an error is thrown. =cut sub validate_obsolete { - my ($class, $bug) = @_; - my $cgi = Bugzilla->cgi; + my ($class, $bug, $list) = @_; # Make sure the attachment id is valid and the user has permissions to view # the bug to which it is attached. Make sure also that the user can view # the attachment itself. my @obsolete_attachments; - foreach my $attachid ($cgi->param('obsolete')) { + foreach my $attachid (@$list) { my $vars = {}; $vars->{'attach_id'} = $attachid; @@ -788,7 +773,8 @@ sub validate_obsolete { || ThrowUserError('invalid_attach_id', $vars); # Check that the user can view and edit this attachment. - $attachment->validate_can_edit($bug->product_id); + $attachment->validate_can_edit($bug->product_id) + || ThrowUserError('illegal_attachment_edit', { attach_id => $attachment->id }); $vars->{'description'} = $attachment->description; @@ -813,144 +799,75 @@ sub validate_obsolete { =pod -=item C +=item C -Description: inserts an attachment from CGI input for the given bug. +Description: inserts an attachment into the given bug. -Params: C<$bug> - Bugzilla::Bug object - the bug for which to insert +Params: takes a hashref with the following keys: + C - Bugzilla::Bug object - the bug for which to insert the attachment. - C<$user> - Bugzilla::User object - the user we're inserting an - attachment for. - C<$timestamp> - scalar - timestamp of the insert as returned - by SELECT NOW(). - C<$hr_vars> - hash reference - reference to a hash of template - variables. + C - Either a filehandle pointing to the content of the + attachment, or the content of the attachment itself. + C - string - describe what the attachment is about. + C - 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 - string - a valid MIME type. + C - string (optional) - timestamp of the insert + as returned by SELECT LOCALTIMESTAMP(0). + C - boolean (optional, default false) - true if the + attachment is a patch. + C - boolean (optional, default false) - true if + the attachment is private. + C - boolean (optional, default false) - true if the + attachment is a URL pointing to some external ressource. + C - boolean (optional, default false) - true + if the attachment must be stored in data/attachments/ instead + of in the DB. -Returns: the ID of the new attachment. +Returns: The new attachment object. =cut -# FIXME: needs to follow the way Object->create() works. sub create { - my ($class, $throw_error, $bug, $user, $timestamp, $hr_vars) = @_; - - my $cgi = Bugzilla->cgi; + my $class = shift; my $dbh = Bugzilla->dbh; - my $attachurl = $cgi->param('attachurl') || ''; - my $data; - my $filename; - my $contenttype; - my $isurl; - $class->validate_is_patch($throw_error) || return; - $class->validate_description($throw_error) || return; - if (Bugzilla->params->{force_attach_bigfile}) - { - # Force uploading into files instead of DB - $cgi->param('bigfile', 1); - } - if (Bugzilla->params->{'allow_attach_url'} - && ($attachurl =~ /^(http|https|ftp):\/\/\S+/) - && !defined $cgi->upload('data')) - { - $filename = ''; - $data = $attachurl; - $isurl = 1; - $contenttype = 'text/plain'; - $cgi->param('ispatch', 0); - $cgi->delete('bigfile'); - } - else { - $filename = _validate_filename($throw_error) || return; - # need to validate content type before data as - # we now check the content type for image/bmp in _validate_data() - unless ($cgi->param('ispatch')) { - $class->validate_content_type($throw_error) || return; + $class->check_required_create_fields(@_); + my $params = $class->run_create_validators(@_); - # Set the ispatch flag to 1 if we're set to autodetect - # and the content type is text/x-diff or text/x-patch - if ($cgi->param('contenttypemethod') eq 'autodetect' - && $cgi->param('contenttype') =~ m{text/x-(?:diff|patch)}) - { - $cgi->param('ispatch', 1); - $cgi->param('contenttype', 'text/plain'); - } - } - $data = _validate_data($throw_error, $hr_vars); - # If the attachment is stored locally, $data eq ''. - # If an error is thrown, $data eq '0'. - ($data ne '0') || return; - $contenttype = $cgi->param('contenttype'); + # Extract everything which is not a valid column name. + my $bug = delete $params->{bug}; + $params->{bug_id} = $bug->id; + my $fh = delete $params->{data}; + my $store_in_file = delete $params->{store_in_file}; - # These are inserted using placeholders so no need to panic - trick_taint($filename); - trick_taint($contenttype); - $isurl = 0; - } - - # Check attachments the user tries to mark as obsolete. - my @obsolete_attachments; - if ($cgi->param('obsolete')) { - @obsolete_attachments = $class->validate_obsolete($bug); - } - - # The order of these function calls is important, as Flag::validate - # assumes User::match_field has ensured that the - # values in the requestee fields are legitimate user email addresses. - my $match_status = Bugzilla::User::match_field($cgi, { - '^requestee(_type)?-(\d+)$' => { 'type' => 'multi' }, - }, MATCH_SKIP_CONFIRM); - - $hr_vars->{'match_field'} = 'requestee'; - if ($match_status == USER_MATCH_FAILED) { - $hr_vars->{'message'} = 'user_match_failed'; - } - elsif ($match_status == USER_MATCH_MULTIPLE) { - $hr_vars->{'message'} = 'user_match_multiple'; - } - - # Escape characters in strings that will be used in SQL statements. - my $description = $cgi->param('description'); - trick_taint($description); - my $isprivate = $cgi->param('isprivate') ? 1 : 0; - - # Insert the attachment into the database. - my $sth = $dbh->do( - "INSERT INTO attachments - (bug_id, creation_ts, modification_time, filename, description, - mimetype, ispatch, isurl, isprivate, submitter_id) - VALUES (?,?,?,?,?,?,?,?,?,?)", undef, ($bug->bug_id, $timestamp, $timestamp, - $filename, $description, $contenttype, $cgi->param('ispatch'), - $isurl, $isprivate, $user->id)); - # Retrieve the ID of the newly created attachment record. - my $attachid = $dbh->bz_last_key('attachments', 'attach_id'); + my $attachment = $class->insert_create_data($params); + my $attachid = $attachment->id; # We only use $data here in this INSERT with a placeholder, # so it's safe. - $sth = $dbh->prepare("INSERT INTO attach_data - (id, thedata) VALUES ($attachid, ?)"); - if (!$cgi->param('bigfile') && $data) - { - trick_taint($data); - $sth->bind_param(1, $data, $dbh->BLOB_TYPE); - $sth->execute(); - } + my $sth = $dbh->prepare("INSERT INTO attach_data + (id, thedata) VALUES ($attachid, ?)"); + + my $data = $store_in_file ? "" : $fh; + trick_taint($data); + $sth->bind_param(1, $data, $dbh->BLOB_TYPE); + $sth->execute(); # If the file is to be stored locally, stream the file from the web server # to the local file without reading it into a local variable. - if ($cgi->param('bigfile')) - { + if ($store_in_file) { my $attachdir = bz_locations()->{'attachdir'}; my $hash = ($attachid % 100) + 100; $hash =~ s/.*(\d\d)$/group.$1/; mkdir "$attachdir/$hash", 0770; chmod 0770, "$attachdir/$hash"; - open AH, ">$attachdir/$hash/attachment.$attachid" or die "Could not write into $attachdir/$hash/attachment.$attachid: $!"; + open(AH, '>', "$attachdir/$hash/attachment.$attachid") or die "Could not write into $attachdir/$hash/attachment.$attachid: $!"; binmode AH; - if (my $fh = $cgi->upload('data')) - { + if (ref $fh) { + my $limit = Bugzilla->params->{"maxlocalattachment"} * 1048576; my $sizecount = 0; - my $limit = (Bugzilla->params->{"maxlocalattachment"} * 1048576); while (<$fh>) { print AH $_; $sizecount += length($_); @@ -958,64 +875,73 @@ sub create { close AH; close $fh; unlink "$attachdir/$hash/attachment.$attachid"; - $throw_error ? ThrowUserError("local_file_too_large") : return; + ThrowUserError("local_file_too_large"); } } close $fh; } - elsif ($data) - { - print AH $data; + else { + print AH $fh; } close AH; } - # Make existing attachments obsolete. - my $fieldid = get_field_id('attachments.isobsolete'); - - foreach my $obsolete_attachment (@obsolete_attachments) { - # If the obsolete attachment has request flags, cancel them. - # This call must be done before updating the 'attachments' table. - Bugzilla::Flag->CancelRequests($bug, $obsolete_attachment, $timestamp); - - $dbh->do('UPDATE attachments SET isobsolete = 1, modification_time = ? - WHERE attach_id = ?', - undef, ($timestamp, $obsolete_attachment->id)); - - $dbh->do('INSERT INTO bugs_activity (bug_id, attach_id, who, bug_when, - fieldid, removed, added) - VALUES (?,?,?,?,?,?,?)', - undef, ($bug->bug_id, $obsolete_attachment->id, $user->id, - $timestamp, $fieldid, 0, 1)); - } - - my $attachment = new Bugzilla::Attachment($attachid); - - # 1. Add flags, if any. To avoid dying if something goes wrong - # while processing flags, we will eval() flag validation. - # This requires errors to die(). - # XXX: this can go away as soon as flag validation is able to - # fail without dying. - # - # 2. Flag::validate() should not detect any reference to existing flags - # when creating a new attachment. Setting the third param to -1 will - # force this function to check this point. - my $error_mode_cache = Bugzilla->error_mode; - Bugzilla->error_mode(ERROR_MODE_DIE); - eval { - Bugzilla::Flag::validate($bug->bug_id, -1, SKIP_REQUESTEE_ON_ERROR); - Bugzilla::Flag->process($bug, $attachment, $timestamp, $hr_vars); - }; - Bugzilla->error_mode($error_mode_cache); - if ($@) { - $hr_vars->{'message'} = 'flag_creation_failed'; - $hr_vars->{'flag_creation_error'} = $@; - } - # 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->{filename} = $class->_check_filename($params->{filename}, $params->{isurl}); + $params->{creation_ts} ||= Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + $params->{modification_time} = $params->{creation_ts}; + $params->{submitter_id} = Bugzilla->user->id || ThrowCodeError('invalid_user'); + + return $params; +} + +sub update { + my $self = shift; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my $timestamp = shift || $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + + 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 @@ -1042,4 +968,67 @@ sub remove_from_db { $dbh->bz_commit_transaction(); } +############################### +#### Helpers ##### +############################### + +# Extract the content type from the attachment form. +my $lwp_read_mime_types; +sub get_content_type { + my $cgi = Bugzilla->cgi; + + return 'text/plain' if ($cgi->param('ispatch') || + $cgi->param('text_attachment') !~ /^\s*$/so || + $cgi->param('attachurl')); + + my $content_type; + if (!defined $cgi->param('contenttypemethod')) { + ThrowUserError("missing_content_type_method"); + } + elsif ($cgi->param('contenttypemethod') eq 'autodetect') { + defined $cgi->upload('data') || ThrowUserError('file_not_specified'); + # The user asked us to auto-detect the content type, so use the type + # specified in the HTTP request headers. + $content_type = + $cgi->uploadInfo($cgi->param('data'))->{'Content-Type'}; + if (!_valid_content_type($content_type) && Bugzilla->params->{mime_types_file}) + { + if (!$lwp_read_mime_types) + { + LWP::MediaTypes::read_media_types(Bugzilla->params->{mime_types_file}); + $lwp_read_mime_types = 1; + } + my $file = $cgi->param('data'); + $content_type = LWP::MediaTypes::guess_media_type("$file"); + } + if (!_valid_content_type($content_type)) + { + $content_type = 'application/octet-stream'; + } + $content_type || ThrowUserError("missing_content_type"); + + # Set the ispatch flag to 1 if the content type + # is text/x-diff or text/x-patch + if ($content_type =~ m{text/x-(?:diff|patch)}) { + $cgi->param('ispatch', 1); + $content_type = 'text/plain'; + } + } + elsif ($cgi->param('contenttypemethod') eq 'list') { + # The user selected a content type from the list, so use their + # selection. + $content_type = $cgi->param('contenttypeselection'); + } + elsif ($cgi->param('contenttypemethod') eq 'manual') { + # The user entered a content type manually, so use their entry. + $content_type = $cgi->param('contenttypeentry'); + } + else { + ThrowCodeError("illegal_content_type_method", + { contenttypemethod => $cgi->param('contenttypemethod') }); + } + return $content_type; +} + + 1; diff --git a/Bugzilla/Auth.pm b/Bugzilla/Auth.pm index 4a8dbfbb3..469a0deaf 100644 --- a/Bugzilla/Auth.pm +++ b/Bugzilla/Auth.pm @@ -32,6 +32,9 @@ use fields qw( 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; @@ -120,7 +123,7 @@ sub can_change_password { my $verifier = $self->{_verifier}->{successful}; $verifier ||= $self->{_verifier}; my $getter = $self->{_info_getter}->{successful}; - $getter = $self->{_info_getter} + $getter = $self->{_info_getter} if (!$getter || $getter->isa('Bugzilla::Auth::Login::Cookie')); return $verifier->can_change_password && $getter->user_can_create_account; @@ -185,7 +188,10 @@ sub _handle_login_result { # the password was just wrong. (This makes it harder for a cracker # to find account names by brute force) elsif ($fail_code == AUTH_LOGINFAILED or $fail_code == AUTH_NO_SUCH_USER) { - ThrowUserError("invalid_username_or_password"); + my $remaining_attempts = MAX_LOGIN_ATTEMPTS + - ($result->{failure_count} || 0); + ThrowUserError("invalid_username_or_password", + { remaining => $remaining_attempts }); } # The account may be disabled elsif ($fail_code == AUTH_DISABLED) { @@ -196,6 +202,40 @@ sub _handle_login_result { 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}); + my $vars = { + locked_user => $user, + attempts => $attempts, + unlock_at => $unlock_at, + }; + my $message; + $template->process('email/lockout.txt.tmpl', $vars, \$message) + || ThrowTemplateError($template->error); + MessageToMTA($message); + } + + $unlock_at->set_time_zone($user->timezone); + ThrowUserError('account_locked', + { ip_addr => $determiner->{ip_addr}, unlock_at => $unlock_at }); + } # If we get here, then we've run out of options, which shouldn't happen. else { ThrowCodeError("authres_unhandled", { value => $fail_code }); @@ -257,6 +297,11 @@ various fields to be used in the error message. An incorrect username or password was given. +The hashref may also contain a C element, which specifies +how many times the account has failed to log in within the lockout +period (see L). This is used to warn the user when +he is getting close to being locked out. + =head2 C This is an optional more-specific version of C. @@ -274,6 +319,15 @@ should never be communicated to the user, for security reasons. The user successfully logged in, but their account has been disabled. Usually this is throw only by C. +=head2 C + +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). + +The hashref will also contain a C element, representing the +L whose account is locked out. + =head1 LOGIN TYPES The C function (below) can do different types of login, depending diff --git a/Bugzilla/Auth/Login/CGI.pm b/Bugzilla/Auth/Login/CGI.pm index 5be98aa7a..8e877b951 100644 --- a/Bugzilla/Auth/Login/CGI.pm +++ b/Bugzilla/Auth/Login/CGI.pm @@ -40,12 +40,10 @@ use Bugzilla::Error; sub get_login_info { my ($self) = @_; - my $cgi = Bugzilla->cgi; + my $params = Bugzilla->input_params; - my $username = trim($cgi->param("Bugzilla_login")); - my $password = $cgi->param("Bugzilla_password"); - - $cgi->delete('Bugzilla_login', 'Bugzilla_password'); + my $username = trim(delete $params->{"Bugzilla_login"}); + my $password = delete $params->{"Bugzilla_password"}; if (!defined $username || !defined $password) { return { failure => AUTH_NODATA }; @@ -59,21 +57,8 @@ sub fail_nodata { my $cgi = Bugzilla->cgi; my $template = Bugzilla->template; - if (Bugzilla->error_mode == Bugzilla::Constants::ERROR_MODE_DIE_SOAP_FAULT) { - die SOAP::Fault - ->faultcode(ERROR_AUTH_NODATA) - ->faultstring('Login Required'); - } - - # If system is not configured to never require SSL connections - # we want to always redirect to SSL since passing usernames and - # passwords over an unprotected connection is a bad idea. If we - # get here then a login form will be provided to the user so we - # want this to be protected if possible. - if ($cgi->protocol ne 'https' && Bugzilla->params->{'sslbase'} ne '' - && Bugzilla->params->{'ssl'} ne 'never') - { - $cgi->require_https(Bugzilla->params->{'sslbase'}); + if (Bugzilla->usage_mode != USAGE_MODE_BROWSER) { + ThrowUserError('login_required'); } print $cgi->header(); diff --git a/Bugzilla/Auth/Login/Cookie.pm b/Bugzilla/Auth/Login/Cookie.pm index e2cd8f5ee..570988f7e 100644 --- a/Bugzilla/Auth/Login/Cookie.pm +++ b/Bugzilla/Auth/Login/Cookie.pm @@ -35,8 +35,7 @@ sub get_login_info { my $cgi = Bugzilla->cgi; my $dbh = Bugzilla->dbh; - my $ip_addr = $cgi->remote_addr(); - my $net_addr = get_netaddr($ip_addr); + my $ip_addr = remote_ip(); my $login_cookie = $cgi->cookie("Bugzilla_logincookie"); my $user_id = $cgi->cookie("Bugzilla_login"); @@ -60,24 +59,16 @@ sub get_login_info { trick_taint($login_cookie); detaint_natural($user_id); - my $query = "SELECT userid - FROM logincookies - WHERE logincookies.cookie = ? - AND logincookies.userid = ? - AND (logincookies.ipaddr = ?"; - - # If we have a network block that's allowed to use this cookie, - # as opposed to just a single IP. - my @params = ($login_cookie, $user_id, $ip_addr); - if (defined $net_addr) { - trick_taint($net_addr); - $query .= " OR logincookies.ipaddr = ?"; - push(@params, $net_addr); - } - $query .= ")"; + my $is_valid = + $dbh->selectrow_array('SELECT 1 + FROM logincookies + WHERE cookie = ? + AND userid = ? + AND (ipaddr = ? OR ipaddr IS NULL)', + undef, ($login_cookie, $user_id, $ip_addr)); # If the cookie is valid, return a valid username. - if ($dbh->selectrow_array($query, undef, @params)) { + if ($is_valid) { # If we logged in successfully, then update the lastused # time on the login cookie $dbh->do("UPDATE logincookies SET lastused = NOW() diff --git a/Bugzilla/Auth/Login/Stack.pm b/Bugzilla/Auth/Login/Stack.pm index a5752f22b..bef9171c9 100644 --- a/Bugzilla/Auth/Login/Stack.pm +++ b/Bugzilla/Auth/Login/Stack.pm @@ -35,7 +35,7 @@ sub new { my $list = shift; my %methods = map { $_ => "Bugzilla/Auth/Login/$_.pm" } split(',', $list); lock_keys(%methods); - Bugzilla::Hook::process('auth-login_methods', { modules => \%methods }); + Bugzilla::Hook::process('auth_login_methods', { modules => \%methods }); $self->{_stack} = []; foreach my $login_method (split(',', $list)) { diff --git a/Bugzilla/Auth/Persist/Cookie.pm b/Bugzilla/Auth/Persist/Cookie.pm index c533252d3..232212075 100644 --- a/Bugzilla/Auth/Persist/Cookie.pm +++ b/Bugzilla/Auth/Persist/Cookie.pm @@ -48,18 +48,16 @@ sub persist_login { my ($self, $user) = @_; my $dbh = Bugzilla->dbh; my $cgi = Bugzilla->cgi; + my $input_params = Bugzilla->input_params; - my $ip_addr = $cgi->remote_addr; - unless ($cgi->param('Bugzilla_restrictlogin') || - Bugzilla->params->{'loginnetmask'} == 32) - { - $ip_addr = get_netaddr($ip_addr); + my $ip_addr; + if ($input_params->{'Bugzilla_restrictlogin'}) { + $ip_addr = remote_ip(); + # The IP address is valid, at least for comparing with itself in a + # subsequent login + trick_taint($ip_addr); } - # The IP address is valid, at least for comparing with itself in a - # subsequent login - trick_taint($ip_addr); - $dbh->bz_start_transaction(); my $login_cookie = @@ -83,17 +81,15 @@ sub persist_login { # or admin didn't forbid it and user told to remember. if ( Bugzilla->params->{'rememberlogin'} eq 'on' || (Bugzilla->params->{'rememberlogin'} ne 'off' && - $cgi->param('Bugzilla_remember') && - $cgi->param('Bugzilla_remember') eq 'on') ) + $input_params->{'Bugzilla_remember'} && + $input_params->{'Bugzilla_remember'} eq 'on') ) { # Not a session cookie, so set an infinite expiry $cookieargs{'-expires'} = 'Fri, 01-Jan-2038 00:00:00 GMT'; } - if (Bugzilla->params->{'ssl'} ne 'never' - && Bugzilla->params->{'sslbase'} ne '') - { - # Bugzilla->login will automatically redirect to https://, - # so it's safe to turn on the 'secure' bit. + if (Bugzilla->params->{'ssl_redirect'}) { + # Make these cookies only be sent to us by the browser during + # HTTPS sessions, if we're using SSL. $cookieargs{'-secure'} = 1; } diff --git a/Bugzilla/Auth/Verify/DB.pm b/Bugzilla/Auth/Verify/DB.pm index e3dffcd02..d8794472e 100644 --- a/Bugzilla/Auth/Verify/DB.pm +++ b/Bugzilla/Auth/Verify/DB.pm @@ -41,37 +41,51 @@ sub check_credentials { my $dbh = Bugzilla->dbh; my $username = $login_data->{username}; - my $user_id = login_to_id($username); - - return { failure => AUTH_NO_SUCH_USER } unless $user_id; + 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 }; + } - $login_data->{bz_username} = $username; my $password = $login_data->{password}; - - trick_taint($username); - my ($real_password_crypted) = $dbh->selectrow_array( - "SELECT cryptpassword FROM profiles WHERE userid = ?", - undef, $user_id); + 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); - - return { failure => AUTH_LOGINFAILED } - if $entered_password_crypted ne $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 }), + }; + } # The user's credentials are okay, so delete any outstanding - # password tokens they may have generated. - Bugzilla::Token::DeletePasswordTokens($user_id, "user_logged_in"); + # password tokens or login failures they may have generated. + Bugzilla::Token::DeletePasswordTokens($user->id, "user_logged_in"); + $user->clear_login_failures(); # If their old password was using crypt() or some different hash # than we're using now, convert the stored password to using # whatever hashing system we're using now. my $current_algorithm = PASSWORD_DIGEST_ALGORITHM; if ($real_password_crypted !~ /{\Q$current_algorithm\E}$/) { - my $new_crypted = bz_crypt($password); - $dbh->do('UPDATE profiles SET cryptpassword = ? WHERE userid = ?', - undef, $new_crypted, $user_id); + $user->set_password($password); + $user->update(); } return $login_data; diff --git a/Bugzilla/Auth/Verify/LDAP.pm b/Bugzilla/Auth/Verify/LDAP.pm index b5904301d..cdc802ca0 100644 --- a/Bugzilla/Auth/Verify/LDAP.pm +++ b/Bugzilla/Auth/Verify/LDAP.pm @@ -56,7 +56,7 @@ sub check_credentials { # just appending the Base DN to the uid isn't sufficient to get the # user's DN. For servers which don't work this way, there will still # be no harm done. - $self->_bind_ldap_anonymously(); + $self->_bind_ldap_for_search(); # Now, we verify that the user exists, and get a LDAP Distinguished # Name for the user. @@ -76,12 +76,35 @@ sub check_credentials { return { failure => AUTH_LOGINFAILED } if $pw_result->code; # And now we fill in the user's details. - my $detail_result = $self->ldap->search(_bz_search_params($username)); - return { failure => AUTH_ERROR, error => "ldap_search_error", - details => {errstr => $detail_result->error, username => $username} - } if $detail_result->code; - my $user_entry = $detail_result->shift_entry; + # 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) { @@ -128,7 +151,7 @@ sub _bz_search_params { . Bugzilla->params->{"LDAPfilter"} . ')'); } -sub _bind_ldap_anonymously { +sub _bind_ldap_for_search { my ($self) = @_; my $bind_result; if (Bugzilla->params->{"LDAPbinddn"}) { diff --git a/Bugzilla/Auth/Verify/Stack.pm b/Bugzilla/Auth/Verify/Stack.pm index c23b532fd..2df3fcd25 100644 --- a/Bugzilla/Auth/Verify/Stack.pm +++ b/Bugzilla/Auth/Verify/Stack.pm @@ -30,7 +30,7 @@ sub new { 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 }); + Bugzilla::Hook::process('auth_verify_methods', { modules => \%methods }); $self->{_stack} = []; foreach my $verify_method (split(',', $list)) { diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm index dfd18b0b5..de789bfd2 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -39,13 +39,16 @@ use Bugzilla::FlagType; use Bugzilla::FlagType::UserList; 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 List::Util qw(min); use Storable qw(dclone); @@ -108,9 +111,9 @@ sub DB_COLUMNS { $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 }); - + + Bugzilla::Hook::process("bug_columns", { columns => \@columns }); + return @columns; } @@ -124,24 +127,27 @@ use constant REQUIRED_CREATE_FIELDS => qw( # There are also other, more complex validators that are called # from run_create_validators. sub VALIDATORS { + my $cache = Bugzilla->request_cache; + return $cache->{bug_validators} if defined $cache->{bug_validators}; + my $validators = { alias => \&_check_alias, bug_file_loc => \&_check_bug_file_loc, - bug_severity => \&_check_bug_severity, + bug_severity => \&_check_select_field, comment => \&_check_comment, commentprivacy => \&_check_commentprivacy, deadline => \&_check_deadline, estimated_time => \&_check_estimated_time, - op_sys => \&_check_op_sys, + op_sys => \&_check_select_field, priority => \&_check_priority, product => \&_check_product, remaining_time => \&_check_remaining_time, - rep_platform => \&_check_rep_platform, + rep_platform => \&_check_select_field, short_desc => \&_check_short_desc, status_whiteboard => \&_check_status_whiteboard, }; - # Set up validators for custom fields. + # Set up validators for custom fields. foreach my $field (Bugzilla->active_custom_fields) { my $validator; if ($field->type == FIELD_TYPE_SINGLE_SELECT) { @@ -165,7 +171,8 @@ sub VALIDATORS { $validators->{$field->name} = $validator; } - return $validators; + $cache->{bug_validators} = $validators; + return $cache->{bug_validators}; }; use constant UPDATE_VALIDATORS => { @@ -239,6 +246,27 @@ use constant UPDATE_COMMENT_COLUMNS => qw( # 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 => { + creation_time => 'creation_ts', + description => 'comment', + id => 'bug_id', + 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', +}; + ##################################################################### sub new { @@ -383,12 +411,12 @@ sub match { # 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} + $params->{$field} = $translated{$field} if exists $translated{$field}; } foreach my $field (qw(product component)) { delete $params->{$field}; - $params->{"${field}_id"} = $translated{$field} + $params->{"${field}_id"} = $translated{$field} if exists $translated{$field}; } @@ -425,10 +453,10 @@ sub match { # # C - The full login name of the user who the bug is # initially assigned to. -# C - The full login name of the QA Contact for this bug. +# C - The full login name of the QA Contact for this bug. # Will be ignored if C is off. # -# C - For time-tracking. Will be ignored if +# C - For time-tracking. Will be ignored if # C is not set, or if the current # user is not a member of the timetrackinggroup. # C - For time-tracking. Will be ignored for the same @@ -443,13 +471,13 @@ sub create { # These fields have default values which we can use if they are undefined. $params->{bug_severity} = Bugzilla->params->{defaultseverity} - unless defined $params->{bug_severity}; + unless defined $params->{bug_severity}; $params->{priority} = Bugzilla->params->{defaultpriority} - unless defined $params->{priority}; + unless defined $params->{priority}; $params->{op_sys} = Bugzilla->params->{defaultopsys} - unless defined $params->{op_sys}; + unless defined $params->{op_sys}; $params->{rep_platform} = Bugzilla->params->{defaultplatform} - unless defined $params->{rep_platform}; + unless defined $params->{rep_platform}; # Make sure a comment is always defined. $params->{comment} = '' unless defined $params->{comment}; @@ -469,12 +497,12 @@ sub create { # Set up the keyword cache for bug creation. my $keywords = $params->{keywords}; - $params->{keywords} = join(', ', sort {lc($a) cmp lc($b)} + $params->{keywords} = join(', ', sort {lc($a) cmp lc($b)} map($_->name, @$keywords)); # 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 $timestamp = delete $params->{creation_ts}; my $ms_values = $class->_extract_multi_selects($params); my $bug = $class->insert_create_data($params); @@ -539,7 +567,7 @@ sub create { # but sometimes it's blank. my @columns = qw(bug_id who bug_when thetext work_time); my @values = ($bug->bug_id, $bug->{reporter_id}, $timestamp, $comment, $work_time); - # We don't include the "isprivate" column unless it was specified. + # We don't include the "isprivate" column unless it was specified. # This allows it to fall back to its database default. if (defined $privacy) { push(@columns, 'isprivate'); @@ -550,7 +578,7 @@ sub create { $dbh->do('INSERT INTO longdescs (' . join(',', @columns) . ") VALUES ($qmarks)", undef, @values); - Bugzilla::Hook::process('bug-end_of_create', { bug => $bug, + Bugzilla::Hook::process('bug_end_of_create', { bug => $bug, timestamp => $timestamp, }); @@ -564,7 +592,6 @@ sub create { return $bug; } - sub run_create_validators { my $class = shift; my $params = $class->SUPER::run_create_validators(@_); @@ -600,16 +627,16 @@ sub run_create_validators { $params->{component_id} = $component->id; delete $params->{component}; - $params->{assigned_to} = + $params->{assigned_to} = $class->_check_assigned_to($params->{assigned_to}, $component); $params->{qa_contact} = $class->_check_qa_contact($params->{qa_contact}, $component); $params->{cc} = $class->_check_cc($component, $params->{cc}); - # Callers cannot set Reporter, currently. + # Callers cannot set reporter, creation_ts, or delta_ts. $params->{reporter} = $class->_check_reporter(); - - $params->{creation_ts} ||= Bugzilla->dbh->selectrow_array('SELECT NOW()'); + $params->{creation_ts} = + Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); $params->{delta_ts} = $params->{creation_ts}; if ($params->{estimated_time}) { @@ -621,7 +648,7 @@ sub run_create_validators { $class->_check_strict_isolation($params->{cc}, $params->{assigned_to}, $params->{qa_contact}, $product); - ($params->{dependson}, $params->{blocked}) = + ($params->{dependson}, $params->{blocked}) = $class->_check_dependencies($params->{dependson}, $params->{blocked}, $product); @@ -630,6 +657,9 @@ sub run_create_validators { delete $params->{lastdiffed}; delete $params->{bug_id}; + Bugzilla::Hook::process('bug_end_of_create_validators', + { params => $params }); + return $params; } @@ -640,7 +670,7 @@ sub update { my $user = Bugzilla->user; # XXX This is just a temporary hack until all updating happens # inside this function. - my $delta_ts = shift || $dbh->selectrow_array("SELECT NOW()"); + my $delta_ts = shift || $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); Bugzilla::Hook::process('bug-pre_update', { bug => $self, timestamp => $delta_ts }); @@ -648,14 +678,12 @@ sub update { # 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)) - { + foreach my $field (qw(product_id component_id)) { my $change = delete $changes->{$field}; - if ($change) - { + if ($change) { my $new_field = $field; $new_field =~ s/_id$//; - $changes->{$new_field} = + $changes->{$new_field} = [$self->{"_old_${new_field}_name"}, $self->$new_field]; } } @@ -687,6 +715,7 @@ sub update { my ($restricted_cc, undef) = diff_arrays($self->cc_users, \@new_cc); if (scalar @$restricted_cc) { + # CustIS Bug 38616 - we must print a warning about that we've removed somebody from CC # нужно вывести предупреждение о том, что кое-кого сюда подписывать нельзя! $self->{cc_restrict_group} = $ccg; $self->{restricted_cc} = [ map { $_->login } @$restricted_cc ]; @@ -695,15 +724,14 @@ sub update { { delete $self->{restricted_cc}; } - @new_cc = map {$_->id} @new_cc; 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->do('DELETE FROM cc WHERE bug_id = ? AND ' . $dbh->sql_in('who', $removed_cc), undef, $self->id); } - foreach my $user_id (@$added_cc) - { + foreach my $user_id (@$added_cc) { $dbh->do('INSERT INTO cc (bug_id, who) VALUES (?,?)', undef, $self->id, $user_id); } @@ -715,7 +743,7 @@ sub update { 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}; @@ -723,7 +751,7 @@ sub update { 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->do('DELETE FROM keywords WHERE bug_id = ? AND ' . $dbh->sql_in('keywordid', $removed_kw), undef, $self->id); } foreach my $keyword_id (@$added_kw) { @@ -746,12 +774,12 @@ sub update { 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, '', $user->id, $delta_ts); @@ -762,7 +790,7 @@ sub update { 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, $user->id, $delta_ts); @@ -770,7 +798,7 @@ sub update { $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)]; } @@ -799,6 +827,12 @@ sub update { 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} || []}) { @@ -818,7 +852,7 @@ sub update { Bugzilla->user->id, $delta_ts); } } - + foreach my $comment_id (keys %{$self->{comment_isprivate} || {}}) { $dbh->do("UPDATE longdescs SET isprivate = ? WHERE comment_id = ?", undef, $self->{comment_isprivate}->{$comment_id}, $comment_id); @@ -844,7 +878,7 @@ sub update { } # See Also - my ($removed_see, $added_see) = + my ($removed_see, $added_see) = diff_arrays($old_bug->see_also, $self->see_also); if (scalar @$removed_see) { @@ -884,14 +918,13 @@ sub 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, - }); + 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}) { @@ -1019,7 +1052,7 @@ sub remove_from_db { WHERE bug_id = ?", undef, $bug_id); if (scalar(@$attach_ids)) { - $dbh->do("DELETE FROM attach_data WHERE " + $dbh->do("DELETE FROM attach_data WHERE " . $dbh->sql_in('id', $attach_ids)); } @@ -1107,7 +1140,7 @@ sub _check_assigned_to { sub _check_bug_file_loc { my ($invocant, $url) = @_; $url = '' if !defined($url); - # On bug entry, if bug_file_loc is "http://", the default, use an + # 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://') { @@ -1116,13 +1149,6 @@ sub _check_bug_file_loc { return $url; } -sub _check_bug_severity { - my ($invocant, $severity) = @_; - $severity = trim($severity); - check_field('bug_severity', $severity); - return $severity; -} - sub _check_bug_status { my ($invocant, $new_status, $product, $comment, $assigned_to) = @_; my $user = Bugzilla->user; @@ -1130,7 +1156,7 @@ sub _check_bug_status { my $old_status; # Note that this is undef for new bugs. if (ref $invocant) { - @valid_statuses = @{$invocant->status->can_change_to}; + @valid_statuses = @{$invocant->statuses_available}; $product = $invocant->product_obj; $old_status = $invocant->status; my $comments = $invocant->{added_comments} || []; @@ -1138,12 +1164,9 @@ sub _check_bug_status { } else { @valid_statuses = @{Bugzilla::Status->can_change_to()}; - } - - if (!$product->votes_to_confirm) { - # UNCONFIRMED becomes an invalid status if votes_to_confirm is 0, - # even if you are in editbugs. - @valid_statuses = grep {$_->name ne 'UNCONFIRMED'} @valid_statuses; + if (!$product->allows_unconfirmed) { + @valid_statuses = grep {$_->name ne 'UNCONFIRMED'} @valid_statuses; + } } if ($assigned_to && $user->email ne $assigned_to) @@ -1180,9 +1203,13 @@ sub _check_bug_status { } } } + # Time to validate the bug status. $new_status = Bugzilla::Status->check($new_status) unless ref($new_status); - if (!grep {$_->name eq $new_status->name} @valid_statuses) { + # 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 }); } @@ -1192,9 +1219,9 @@ sub _check_bug_status { { ThrowUserError('comment_required', { old => $old_status, new => $new_status }); - + } - + if (ref $invocant && $new_status->name eq 'ASSIGNED' && Bugzilla->params->{"usetargetmilestone"} && Bugzilla->params->{"musthavemilestoneonaccept"} @@ -1214,6 +1241,9 @@ sub _check_cc { my ($invocant, $component, $ccs) = @_; return [map {$_->id} @{$component->initial_cc}] unless $ccs; + # Allow comma-separated input as well as arrayrefs. + $ccs = [split(/[\s,]+/, $ccs)] if !ref $ccs; + my %cc_ids; my ($ccg) = $component->product->description =~ /\[[CС]{2}:\s*([^\]]+)\s*\]/iso; foreach my $person (@$ccs) { @@ -1256,7 +1286,7 @@ sub _check_commentprivacy { sub _check_comment_type { my ($invocant, $type) = @_; detaint_natural($type) - || ThrowCodeError('bad_arg', { argument => 'type', + || ThrowCodeError('bad_arg', { argument => 'type', function => caller }); return $type; } @@ -1272,13 +1302,12 @@ sub _check_component { sub _check_deadline { my ($invocant, $date) = @_; - + # Check time-tracking permissions. - my $tt_group = Bugzilla->params->{"timetrackinggroup"}; # deadline() returns '' instead of undef if no deadline is set. my $current = ref $invocant ? ($invocant->deadline || undef) : undef; - return $current unless $tt_group && Bugzilla->user->in_group($tt_group); - + return $current unless Bugzilla->user->is_timetracker; + # Validate entered deadline $date = trim($date); return undef if !$date; @@ -1302,22 +1331,22 @@ sub _check_dependencies { my %deps_in = (dependson => $depends_on || '', blocked => $blocks || ''); foreach my $type qw(dependson blocked) { - my @bug_ids = ref($deps_in{$type}) - ? @{$deps_in{$type}} + my @bug_ids = ref($deps_in{$type}) + ? @{$deps_in{$type}} : split(/[\s,]+/, $deps_in{$type}); # Eliminate nulls. @bug_ids = grep {$_} @bug_ids; # We do this up here to make sure all aliases are converted to IDs. @bug_ids = map { $invocant->check($_, $type)->id } @bug_ids; - + my @check_access = @bug_ids; - # When we're updating a bug, only added or removed bug_ids are + # 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; @@ -1339,7 +1368,7 @@ sub _check_dependencies { } } } - + $deps_in{$type} = \@bug_ids; } @@ -1353,7 +1382,7 @@ sub _check_dependencies { sub _check_dup_id { my ($self, $dupe_of) = @_; my $dbh = Bugzilla->dbh; - + $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 @@ -1403,8 +1432,8 @@ sub _check_dup_id { $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 + # 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... @@ -1458,9 +1487,9 @@ sub _check_groups { my $membercontrol = $controls->{$id} && $controls->{$id}->{membercontrol}; - my $othercontrol = $controls->{$id} + my $othercontrol = $controls->{$id} && $controls->{$id}->{othercontrol}; - + my $permit = ($membercontrol && $user->in_group($group->name)) || $othercontrol; @@ -1475,7 +1504,7 @@ sub _check_groups { # Add groups required if ($membercontrol == CONTROLMAPMANDATORY || ($othercontrol == CONTROLMAPMANDATORY - && !$user->in_group_id($id))) + && !$user->in_group_id($id))) { # User had no option, bug needs to be in this group. $add_groups{$id} = 1; @@ -1490,12 +1519,12 @@ sub _check_keywords { my ($invocant, $keyword_string, $product) = @_; $keyword_string = trim($keyword_string); return [] if !$keyword_string; - + # On creation, only editbugs users can set keywords. if (!ref $invocant) { return [] if !Bugzilla->user->in_group('editbugs', $product->id); } - + my %keywords; foreach my $keyword (split(/[\s,]+/, $keyword_string)) { next unless $keyword; @@ -1522,29 +1551,18 @@ sub _check_product { return new Bugzilla::Product({ name => $name }); } -sub _check_op_sys { - my ($invocant, $op_sys) = @_; - return Bugzilla->params->{defaultopsys} unless Bugzilla->params->{useopsys}; - $op_sys = trim($op_sys); - check_field('op_sys', $op_sys); - return $op_sys; -} - sub _check_priority { my ($invocant, $priority) = @_; if (!ref $invocant && !Bugzilla->params->{'letsubmitterchoosepriority'}) { $priority = Bugzilla->params->{'defaultpriority'}; } - $priority = trim($priority); - check_field('priority', $priority); - - return $priority; + return $invocant->_check_select_field($priority, 'priority'); } sub _check_qa_contact { my ($invocant, $qa_contact, $component) = @_; $qa_contact = trim($qa_contact) if !ref $qa_contact; - + my $id; if (!ref $invocant) { # Bugs get no QA Contact on creation if useqacontact is off. @@ -1557,7 +1575,7 @@ sub _check_qa_contact { $id = $component->default_qa_contact->id; } } - + # If a QA Contact was specified or if we're updating, check # the QA Contact for validity. if (!defined $id && $qa_contact) { @@ -1584,14 +1602,6 @@ sub _check_remaining_time { return $_[0]->_check_time($_[1], 'remaining_time'); } -sub _check_rep_platform { - my ($invocant, $platform) = @_; - return Bugzilla->params->{defaultplatform} unless Bugzilla->params->{useplatform}; - $platform = trim($platform); - check_field('rep_platform', $platform); - return $platform; -} - sub _check_reporter { my $invocant = shift; my $reporter; @@ -1623,19 +1633,19 @@ sub _check_reporter { sub _check_resolution { my ($self, $resolution) = @_; $resolution = trim($resolution); - + # 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 => $self->status->name }) if !$resolution && !$self->status->is_open; - + # Make sure this is a valid resolution. - check_field('resolution', $resolution); + $resolution = $self->_check_select_field($resolution, 'resolution'); # Don't allow open bugs to have resolutions. ThrowUserError('resolution_not_allowed') if $self->status->is_open; - + # Check noresolveonopenblockers. if (Bugzilla->params->{"noresolveonopenblockers"} && $resolution eq 'FIXED') { @@ -1648,13 +1658,13 @@ sub _check_resolution { } # Check if they're changing the resolution and need to comment. - if (Bugzilla->params->{'commentonchange_resolution'} - && $self->resolution && $resolution ne $self->resolution + if (Bugzilla->params->{'commentonchange_resolution'} + && $self->resolution && $resolution ne $self->resolution && !$self->{added_comments}) { ThrowUserError('comment_required'); } - + return $resolution; } @@ -1750,9 +1760,9 @@ sub _check_target_milestone { $target = trim($target); $target = $product->default_milestone if !defined $target; - check_field('target_milestone', $target, - [map($_->name, @{$product->milestones})], undef, { product => $product }); - return $target; + my $object = Bugzilla::Milestone->check( + { product => $product, name => $target }); + return $object->name; } sub _check_time { @@ -1762,9 +1772,8 @@ sub _check_time { if (ref $invocant && $field ne 'work_time') { $current = $invocant->$field; } - my $tt_group = Bugzilla->params->{"timetrackinggroup"}; - return $current unless $tt_group && Bugzilla->user->in_group($tt_group); - + return $current unless Bugzilla->user->is_timetracker; + $time = ValidateTime($time, $field); return $time; } @@ -1773,8 +1782,9 @@ sub _check_version { my ($invocant, $version, $product) = @_; $version = trim($version); ($product = $invocant->product_obj) if ref $invocant; - check_field('version', $version, [map($_->name, @{$product->versions})], undef, { product => $product }); - return $version; + my $object = + Bugzilla::Version->check({ product => $product, name => $version }); + return $object->name; } sub _check_work_time { @@ -1819,20 +1829,29 @@ sub _check_freetext_field { sub _check_multi_select_field { my ($invocant, $values, $field) = @_; - return [] if !$values; - foreach my $value (@$values) { - $value = trim($value); - check_field($field, $value); - trick_taint($value); + + # 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 $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) = @_; - $value = trim($value); - check_field($field, $value); - return $value; + my $object = Bugzilla::Field::Choice->type($field)->check($value); + return $object->name; } sub _check_bugid_field { @@ -1848,8 +1867,8 @@ sub _check_bugid_field { sub fields { my $class = shift; - my @fields = - ( + my @fields = + ( # Standard Fields # Keep this ordering in sync with bugzilla.dtd. qw(bug_id alias creation_ts short_desc delta_ts @@ -1874,13 +1893,13 @@ sub fields { # Custom Fields map { $_->name } Bugzilla->active_custom_fields ); - Bugzilla::Hook::process("bug-fields", {'fields' => \@fields} ); - + Bugzilla::Hook::process('bug_fields', {'fields' => \@fields} ); + return @fields; } ##################################################################### -# Mutators +# Mutators ##################################################################### # To run check_can_change_field. @@ -1932,12 +1951,12 @@ sub set_cclist_accessible { $_[0]->set('cclist_accessible', $_[1]); } sub set_comment_is_private { my ($self, $comment_id, $isprivate) = @_; return unless Bugzilla->user->is_insider; - my ($comment) = grep($comment_id eq $_->{id}, @{$self->longdescs}); - ThrowUserError('comment_invalid_isprivate', { id => $comment_id }) + my ($comment) = grep($comment_id == $_->id, @{ $self->comments }); + ThrowUserError('comment_invalid_isprivate', { id => $comment_id }) if !$comment; $isprivate = $isprivate ? 1 : 0; - if ($isprivate != $comment->{isprivate}) { + if ($isprivate != $comment->is_private) { $self->{comment_isprivate} ||= {}; $self->{comment_isprivate}->{$comment_id} = $isprivate; } @@ -1960,6 +1979,7 @@ sub set_component { } sub set_custom_field { my ($self, $field, $value) = @_; + if (ref $value eq 'ARRAY' && $field->type != FIELD_TYPE_MULTI_SELECT) { $value = $value->[0]; } @@ -1984,7 +2004,7 @@ sub set_dup_id { $self->set('dup_id', $dup_id); my $new = $self->dup_id; return if $old == $new; - + # Update the other bug. my $dupe_of = new Bugzilla::Bug($self->dup_id); if (delete $self->{_add_dup_cc}) { @@ -1993,7 +2013,7 @@ sub set_dup_id { $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.) @@ -2011,6 +2031,11 @@ sub set_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]); } @@ -2018,7 +2043,7 @@ 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; @@ -2028,7 +2053,6 @@ sub set_product { $self->{_old_product_name} = $old_product->name; # Delete fields that depend upon the old Product value. delete $self->{choices}; - delete $self->{milestoneurl}; $product_changed = 1; } @@ -2066,7 +2090,7 @@ sub set_product { # other part of Bugzilla that checks $@. undef $@; Bugzilla->error_mode($old_error_mode); - + my $verified = $params->{change_confirmed}; my %vars; if (!$verified || !$component_ok || !$version_ok || !$milestone_ok) { @@ -2074,8 +2098,8 @@ sub set_product { # 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, + component => $self->component, + version => $self->version, milestone => $milestone_ok ? $self->target_milestone : $product->default_milestone }; $vars{component_ok} = $component_ok; @@ -2107,9 +2131,9 @@ sub set_product { . Bugzilla->user->groups_as_string . ')) OR gcm.othercontrol != ?) )', undef, (@idlist, $product->id, CONTROLMAPNA, CONTROLMAPNA)); - $vars{'old_groups'} = Bugzilla::Group->new_from_list($gids); + $vars{'old_groups'} = Bugzilla::Group->new_from_list($gids); } - + if (%vars) { $vars{product} = $product; @@ -2119,8 +2143,8 @@ sub set_product { $version_ok || push @ex, 'version'; $component_ok || push @ex, 'component'; $milestone_ok || push @ex, 'target_milestone'; - warn 'EX='.join ',', @ex; - $vars{incorrect_fields} = [ map { get_fielddesc($_) } @ex ]; + my $fd = template_var('field_descs'); + $vars{incorrect_fields} = [ map { $fd->{$_} } @ex ]; $vars{verify_bug_groups} && push @ex, 'bit-\d+'; $vars{exclude_params_re} = '^' . join('|', @ex) . '$'; # Output "Verify new product details" page @@ -2143,7 +2167,7 @@ sub set_product { $self->set_target_milestone($tm_name); } } - + if ($product_changed) { # Remove groups that aren't valid in the new product. This will also # have the side effect of removing the bug from groups that aren't @@ -2157,13 +2181,13 @@ sub set_product { $self->remove_group($group); } } - + # Make sure the bug is in all the mandatory groups for the new product. foreach my $group (@{$product->groups_mandatory_for(Bugzilla->user)}) { $self->add_group($group); } } - + # XXX This is temporary until all of process_bug uses update(); return $product_changed; } @@ -2189,9 +2213,10 @@ sub _zero_remaining_time { } 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) { @@ -2208,7 +2233,7 @@ sub set_resolution { $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 @@ -2227,8 +2252,8 @@ sub clear_resolution { if (!$self->status->is_open) { ThrowUserError('resolution_cant_clear', { bug_id => $self->id }); } - $self->{'resolution'} = ''; - $self->_clear_dup_id; + $self->{'resolution'} = ''; + $self->_clear_dup_id; } sub set_severity { $_[0]->set('bug_severity', $_[1]); } sub set_status { @@ -2236,8 +2261,10 @@ sub set_status { 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); @@ -2326,7 +2353,7 @@ sub add_comment { $params->{type} = $self->_check_comment_type($params->{type}); } if (exists $params->{isprivate}) { - $params->{isprivate} = + $params->{isprivate} = $self->_check_commentprivacy($params->{isprivate}); } # XXX We really should check extra_data, too. @@ -2359,12 +2386,12 @@ sub add_comment { # process_bug to use. sub modify_keywords { my ($self, $keywords, $action) = @_; - + $action ||= "makeexact"; if (!grep($action eq $_, qw(add delete makeexact))) { $action = "makeexact"; } - + $keywords = $self->_check_keywords($keywords); my (@result, $any_changes); @@ -2390,7 +2417,7 @@ sub modify_keywords { } # 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)); @@ -2442,14 +2469,14 @@ sub remove_group { my ($self, $group) = @_; $group = new Bugzilla::Group($group) unless ref $group; return unless $group; - + # First, check if this is a valid group for this product. # You can *always* remove a group that is not valid for this product, so # we don't do any other checks if that's the case. (set_product does this.) # # This particularly happens when isbuggroup is no longer 1, and we're # moving a bug to a new product. - if (grep($_->id == $group->id, @{$self->product_obj->groups_valid})) { + if (grep($_->id == $group->id, @{$self->product_obj->groups_valid})) { my $controls = $self->product_obj->group_controls->{$group->id}; # Nobody can ever remove a Mandatory group. @@ -2471,7 +2498,7 @@ sub remove_group { } } } - + my $current_groups = $self->groups_in; @$current_groups = grep { $_->id != $group->id } @$current_groups; } @@ -2480,7 +2507,7 @@ sub add_see_also { my ($self, $input) = @_; $input = trim($input); - # We assume that the URL is an HTTP URL if there is no (something):// + # We assume that the URL is an HTTP URL if there is no (something):// # in front. my $uri = new URI($input); if (!$uri->scheme) { @@ -2510,10 +2537,53 @@ sub add_see_also { { url => $input, reason => 'id' }); } } + # Google Code URLs + elsif ($uri->authority =~ /^code.google.com$/i) { + # Google Code URLs only have one form: + # http(s)://code.google.com/p/PROJECT_NAME/issues/detail?id=1234 + my $project_name; + if ($uri->path =~ m|^/p/([^/]+)/issues/detail$|) { + $project_name = $1; + } else { + ThrowUserError('bug_url_invalid', + { url => $input }); + } + my $bug_id = $uri->query_param('id'); + detaint_natural($bug_id); + if (!$bug_id) { + ThrowUserError('bug_url_invalid', + { url => $input, reason => 'id' }); + } + # While Google Code URLs can be either HTTP or HTTPS, + # always go with the HTTP scheme, as that's the default. + $result = "http://code.google.com/p/" . $project_name . + "/issues/detail?id=" . $bug_id; + } + # Debian BTS URLs + elsif ($uri->authority =~ /^bugs.debian.org$/i) { + # Debian BTS URLs can look like various things: + # http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1234 + # http://bugs.debian.org/1234 + my $bug_id; + if ($uri->path =~ m|^/(\d+)$|) { + $bug_id = $1; + } + elsif ($uri->path =~ /bugreport\.cgi$/) { + $bug_id = $uri->query_param('bug'); + detaint_natural($bug_id); + } + if (!$bug_id) { + ThrowUserError('bug_url_invalid', + { url => $input, reason => 'id' }); + } + # This is the shortest standard URL form for Debian BTS URLs, + # and so we reduce all URLs to this. + $result = "http://bugs.debian.org/" . $bug_id; + } # Bugzilla URLs else { if ($uri->path !~ /show_bug\.cgi$/) { - ThrowUserError('bug_url_invalid', + ThrowUserError('bug_url_invalid', { url => $input, reason => 'show_bug' }); } @@ -2524,7 +2594,7 @@ sub add_see_also { # we can allow aliases. detaint_natural($bug_id); if (!$bug_id) { - ThrowUserError('bug_url_invalid', + ThrowUserError('bug_url_invalid', { url => $input, reason => 'id' }); } @@ -2589,10 +2659,10 @@ sub dup_id { $self->{'dup_id'} = undef; return if $self->{'error'}; - if ($self->{'resolution'} eq 'DUPLICATE') { + if ($self->{'resolution'} eq 'DUPLICATE') { my $dbh = Bugzilla->dbh; $self->{'dup_id'} = - $dbh->selectrow_array(q{SELECT dupe_of + $dbh->selectrow_array(q{SELECT dupe_of FROM duplicates WHERE dupe = ?}, undef, @@ -2605,14 +2675,13 @@ sub actual_time { my ($self) = @_; return $self->{'actual_time'} if exists $self->{'actual_time'}; - if ( $self->{'error'} || - !Bugzilla->user->in_group(Bugzilla->params->{"timetrackinggroup"}) ) { + 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 + FROM longdescs WHERE longdescs.bug_id=?"); $sth->execute($self->{bug_id}); $self->{'actual_time'} = $sth->fetchrow_array(); @@ -2621,12 +2690,16 @@ sub actual_time { sub any_flags_requesteeble { my ($self) = @_; - return $self->{'any_flags_requesteeble'} + return $self->{'any_flags_requesteeble'} if exists $self->{'any_flags_requesteeble'}; return 0 if $self->{'error'}; - $self->{'any_flags_requesteeble'} = - grep($_->{'is_requesteeble'}, @{$self->flag_types}); + 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'}; } @@ -2683,7 +2756,7 @@ 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); @@ -2734,7 +2807,7 @@ sub dependson { my ($self) = @_; return $self->{'dependson'} if exists $self->{'dependson'}; return [] if $self->{'error'}; - $self->{'dependson'} = + $self->{'dependson'} = EmitDependList("blocked", "dependson", $self->bug_id); return $self->{'dependson'}; } @@ -2749,7 +2822,7 @@ sub flag_types { component_id => $self->{component_id}, bug_id => $self->bug_id }; - $self->{'flag_types'} = Bugzilla::Flag::_flag_types($vars); + $self->{'flag_types'} = Bugzilla::Flag->_flag_types($vars); # Custom list for flag selection - moved from flag/list.html.tmpl # Fucking templaty logic @@ -2766,52 +2839,55 @@ sub flag_types { $cl->add(Assignee => $_) for $self->assigned_to || (); $type->{custom_list} = $cl; $type->{allow_other} = 1; - foreach (@{$type->{flags}}) + foreach my $flag (@{$type->{flags}}) { unless ($type->is_active && $type->is_requestable && $type->is_requesteeble) { # In case there was already a requestee, the only valid action # is to remove the requestee or leave it alone. - $_->{custom_list} = new Bugzilla::FlagType::UserList; - $_->{custom_list}->add('', $_->requestee); - $_->{allow_other} = 0; + $flag->{custom_list} = new Bugzilla::FlagType::UserList; + $flag->{custom_list}->add('', $flag->requestee); + $flag->{allow_other} = 0; } else { # Else take type's custom list - $_->{custom_list} = $cl; - $_->{allow_other} = 1; + $flag->{custom_list} = $cl; + $flag->{allow_other} = 1; } $st = []; # TODO remove hardcoded status list - push @$st, 'X' if $user->can_request_flag($type); + push @$st, 'X' if $user->can_request_flag($type) || $flag->setter_id == $user->id; if ($type->is_active) { - push @$st, '?' if $type->is_requestable && $user->can_request_flag($type) || $_->status == '?'; - push @$st, '+' if $user->can_set_flag($type) || $_->status == '+'; - push @$st, '-' if $user->can_set_flag($type) || $_->status == '-'; + push @$st, '?' if $type->is_requestable && $user->can_request_flag($type) || $flag->status == '?'; + push @$st, '+' if $user->can_set_flag($type) || $flag->status == '+'; + push @$st, '-' if $user->can_set_flag($type) || $flag->status == '-'; } else { - push @$st, $_->status; + push @$st, $flag->status; } - $_->{statuses} = $st; + $flag->{statuses} = $st; } } 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 isunconfirmed { - my $self = shift; - return ($self->bug_status eq 'UNCONFIRMED') ? 1 : 0; -} - sub keywords { my ($self) = @_; return join(', ', (map { $_->name } @{$self->keyword_objects})); @@ -2830,21 +2906,40 @@ sub keyword_objects { return $self->{'keyword_objects'}; } -sub longdescs { - my ($self) = @_; - return $self->{'longdescs'} if exists $self->{'longdescs'}; +sub comments { + my ($self, $params) = @_; return [] if $self->{'error'}; - $self->{'longdescs'} = GetComments($self->{bug_id}); - return $self->{'longdescs'}; -} + $params ||= {}; -sub milestoneurl { - my ($self) = @_; - return $self->{'milestoneurl'} if exists $self->{'milestoneurl'}; - return '' if $self->{'error'}; + 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'} }; - $self->{'milestoneurl'} = $self->product_obj->milestone_url; - return $self->{'milestoneurl'}; + my $order = $params->{order} + || Bugzilla->user->settings->{'comment_sort_order'}->{'value'}; + 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 product { @@ -2873,8 +2968,8 @@ sub qa_contact { if (Bugzilla->params->{'useqacontact'} && $self->{'qa_contact'}) { $self->{'qa_contact_obj'} = new Bugzilla::User($self->{'qa_contact'}); } else { - # XXX - This is somewhat inconsistent with the assignee/reporter - # methods, which will return an empty User if they get a 0. + # XXX - This is somewhat inconsistent with the assignee/reporter + # methods, which will return an empty User if they get a 0. # However, we're keeping it this way now, for backwards-compatibility. $self->{'qa_contact_obj'} = undef; } @@ -2905,9 +3000,39 @@ sub status { return $self->{'status'}; } +sub statuses_available { + my $self = shift; + return [] if $self->{'error'}; + return $self->{'statuses_available'} + if defined $self->{'statuses_available'}; + + my @statuses = @{ $self->status->can_change_to }; + + # UNCONFIRMED is only a valid status if it is enabled in this product. + if (!$self->product_obj->allows_unconfirmed) { + @statuses = grep { $_->name ne 'UNCONFIRMED' } @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); + } + + $self->{'statuses_available'} = \@available; + return $self->{'statuses_available'}; +} + sub show_attachment_flags { my ($self) = @_; - return $self->{'show_attachment_flags'} + return $self->{'show_attachment_flags'} if exists $self->{'show_attachment_flags'}; return 0 if $self->{'error'}; @@ -2933,7 +3058,7 @@ sub use_votes { my ($self) = @_; return 0 if $self->{'error'}; - return Bugzilla->params->{'usevotes'} + return Bugzilla->params->{'usevotes'} && $self->product_obj->votes_per_user > 0; } @@ -2959,7 +3084,7 @@ sub groups { " THEN 1 ELSE 0 END," . " CASE WHEN groups.id IN($grouplist) THEN 1 ELSE 0 END," . " isactive, membercontrol, othercontrol" . - " FROM groups" . + " FROM groups" . " LEFT JOIN bug_group_map" . " ON bug_group_map.group_id = groups.id" . " AND bug_id = ?" . @@ -3042,38 +3167,40 @@ sub user { 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; - $self->{'choices'} = {}; - - my @prodlist = map {$_->name} @{Bugzilla->user->get_enterable_products}; + 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 (lsearch(\@prodlist, $self->product) < 0) { - push(@prodlist, $self->product); - @prodlist = sort @prodlist; + if (!grep($_->name eq $self->product_obj->name, @products)) { + unshift(@products, $self->product_obj); } - $self->{choices} = { - product => \@prodlist, - priority => get_legal_field_values('priority'), - bug_severity => get_legal_field_values('bug_severity'), - bug_status => get_legal_field_values('bug_status'), - component => [map($_->name, @{$self->product_obj->components})], - version => [map($_->name, @{$self->product_obj->versions})], - target_milestone => [map($_->name, @{$self->product_obj->milestones})], - }; + my %choices = ( + bug_status => $self->statuses_available, + product => \@products, + component => $self->product_obj->components, + version => $self->product_obj->versions, + target_milestone => $self->product_obj->milestones, + ); - # Hack - this array contains "". See bug 106589. - $self->{choices}->{resolution} = [ grep ($_, @{get_legal_field_values('resolution')}) ]; + 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 }); + # And don't include MOVED in the list unless the bug is already MOVED. + if ($self->resolution ne 'MOVED') { + @resolutions= grep { $_->name ne 'MOVED' } @resolutions; + } + $choices{'resolution'} = \@resolutions; - $self->{choices}->{op_sys} = get_legal_field_values('op_sys') if Bugzilla->params->{useopsys}; - $self->{choices}->{rep_platform} = get_legal_field_values('rep_platform') if Bugzilla->params->{useplatform}; - - return $self->{choices}; + $self->{'choices'} = \%choices; + return $self->{'choices'}; } sub votes { @@ -3116,37 +3243,6 @@ sub get_test_case_count { # Subroutines ##################################################################### -sub update_comment { - my ($self, $comment_id, $new_comment) = @_; - - # Some validation checks. - if ($self->{'error'}) { - ThrowCodeError("bug_error", { bug => $self }); - } - detaint_natural($comment_id) - || ThrowCodeError('bad_arg', {argument => 'comment_id', function => 'update_comment'}); - - # The comment ID must belong to this bug. - my @current_comment_obj = grep {$_->{'id'} == $comment_id} @{$self->longdescs}; - scalar(@current_comment_obj) - || ThrowCodeError('bad_arg', {argument => 'comment_id', function => 'update_comment'}); - - # If the new comment is undefined, then there is nothing to update. - # To delete a comment, an empty string should be passed. - return unless defined $new_comment; - $new_comment =~ s/\s*$//s; # Remove trailing whitespaces. - $new_comment =~ s/\r\n?/\n/g; # Handle Windows and Mac-style line endings. - trick_taint($new_comment); - - # We assume _check_comment() has already been called earlier. - Bugzilla->dbh->do('UPDATE longdescs SET thetext = ? WHERE comment_id = ?', - undef, ($new_comment, $comment_id)); - $self->_sync_fulltext(); - - # Update the comment object with this new text. - $current_comment_obj[0]->{'body'} = $new_comment; -} - # FIXME // Vitaliy Filippov 2010-02-01 19:23 # editable_bug_fields() is one more example of incorrect and unused generalization. # It does not represent which fields from the bugs table are handled by process_bug.cgi, @@ -3213,74 +3309,6 @@ sub ValidateTime return $time; } -sub GetComments { - my ($id, $comment_sort_order, $start, $end, $raw) = @_; - my $dbh = Bugzilla->dbh; - - $comment_sort_order = $comment_sort_order || - Bugzilla->user->settings->{'comment_sort_order'}->{'value'}; - - my $sort_order = ($comment_sort_order eq "oldest_to_newest") ? 'asc' : 'desc'; - - my @comments; - my @args = ($id); - - my $query = 'SELECT longdescs.comment_id AS id, profiles.userid, ' . - $dbh->sql_date_format('longdescs.bug_when', '%Y.%m.%d %H:%i:%s') . - ' AS time, longdescs.thetext AS body, longdescs.work_time, - isprivate, already_wrapped, type, extra_data - FROM longdescs - INNER JOIN profiles - ON profiles.userid = longdescs.who - WHERE longdescs.bug_id = ?'; - - if ($start) { - $query .= ' AND longdescs.bug_when > ?'; - push(@args, $start); - } - if ($end) { - $query .= ' AND longdescs.bug_when <= ?'; - push(@args, $end); - } - - $query .= " ORDER BY longdescs.bug_when $sort_order"; - my $sth = $dbh->prepare($query); - $sth->execute(@args); - - # Cache the users we look up - my %users; - - while (my $comment_ref = $sth->fetchrow_hashref()) { - my %comment = %$comment_ref; - $users{$comment{'userid'}} ||= new Bugzilla::User($comment{'userid'}); - $comment{'author'} = $users{$comment{'userid'}}; - - # If raw data is requested, do not format 'special' comments. - $comment{'body'} = format_comment(\%comment) unless $raw; - - push (@comments, \%comment); - } - - if ($comment_sort_order eq "newest_to_oldest_desc_first") { - unshift(@comments, pop @comments); - } - - return \@comments; -} - -# Format language specific comments. -sub format_comment { - my $comment = shift; - my $template = Bugzilla->template_inner; - my $vars = {comment => $comment}; - my $body; - - $template->process("bug/format_comment.txt.tmpl", $vars, \$body) - || ThrowTemplateError($template->error()); - $body =~ s/^X//; - return $body; -} - # Get the activity of a bug, starting from $starttime (if given). # This routine assumes Bugzilla::Bug->check has been previously called. sub GetBugActivity { @@ -3307,22 +3335,14 @@ sub GetBugActivity { # Only includes attachments the user is allowed to see. my $suppjoins = ""; my $suppwhere = ""; - if (Bugzilla->params->{"insidergroup"} - && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'})) + if (!Bugzilla->user->is_insider) { - $suppjoins = "LEFT JOIN attachments + $suppjoins = "LEFT JOIN attachments ON attachments.attach_id = bugs_activity.attach_id"; $suppwhere = "AND COALESCE(attachments.isprivate, 0) = 0"; } - my $query = " - SELECT COALESCE(fielddefs.description, " - # This is a hack - PostgreSQL requires both COALESCE - # arguments to be of the same type, and this is the only - # way supported by both MySQL 3 and PostgreSQL to convert - # an integer to a string. MySQL 4 supports CAST. - . $dbh->sql_string_concat('bugs_activity.fieldid', q{''}) . - "), fielddefs.name, bugs_activity.attach_id, " . + 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 FROM bugs_activity @@ -3345,7 +3365,7 @@ sub GetBugActivity { my $incomplete_data = 0; foreach my $entry (@$list) { - my ($field, $fieldname, $attachid, $when, $removed, $added, $who) = @$entry; + my ($fieldname, $attachid, $when, $removed, $added, $who) = @$entry; my %change; my $activity_visible = 1; @@ -3355,16 +3375,12 @@ sub GetBugActivity { || $fieldname eq 'work_time' || $fieldname eq 'deadline') { - $activity_visible = - Bugzilla->user->in_group(Bugzilla->params->{'timetrackinggroup'}) ? 1 : 0; + $activity_visible = Bugzilla->user->is_timetracker; } else { $activity_visible = 1; } if ($activity_visible) { - # This gets replaced with a hyperlink in the template. - $field =~ s/^Attachment\s*// if $attachid; - # Check for the results of an old Bugzilla data corruption bug $incomplete_data = 1 if ($added =~ /^\?/ || $removed =~ /^\?/); @@ -3387,7 +3403,6 @@ sub GetBugActivity { $operation->{'who'} = $who; $operation->{'when'} = $when; - $change{'field'} = $field; $change{'fieldname'} = $fieldname; $change{'attachid'} = $attachid; $change{'removed'} = $removed; @@ -3462,6 +3477,25 @@ sub LogActivityEntry { } } +# Convert WebService API and email_in.pl field names to internal DB field +# names. +sub map_fields { + my ($params) = @_; + + my %field_values; + foreach my $field (keys %$params) { + my $field_name = FIELD_MAP->{$field} || $field; + $field_values{$field_name} = $params->{$field}; + } + + # This protects the WebService Bug.search method. + unless (Bugzilla->user->is_timetracker) { + delete @field_values{qw(estimated_time remaining_time deadline)}; + } + + return \%field_values; +} + # CountOpenDependencies counts the number of open dependent bugs for a # list of bugs and returns a list of bug_id's and their dependency count # It takes one parameter: @@ -3499,7 +3533,7 @@ sub RemoveVotes { my $sth = $dbh->prepare("SELECT profiles.login_name, " . "profiles.userid, votes.vote_count, " . "products.votesperuser, products.maxvotesperbug " . - "FROM profiles " . + "FROM profiles " . "LEFT JOIN votes ON profiles.userid = votes.who " . "LEFT JOIN bugs ON votes.bug_id = bugs.bug_id " . "LEFT JOIN products ON products.id = bugs.product_id " . @@ -3582,7 +3616,10 @@ sub CheckIfVotedConfirmed { my $bug = new Bugzilla::Bug($id); my $ret = 0; - if (!$bug->everconfirmed && $bug->votes >= $bug->product_obj->votes_to_confirm) { + if (!$bug->everconfirmed + and $bug->product_obj->votes_to_confirm + and $bug->votes >= $bug->product_obj->votes_to_confirm) + { $bug->add_comment('', { type => CMT_POPULAR_VOTES }); if ($bug->bug_status eq 'UNCONFIRMED') { @@ -3667,11 +3704,10 @@ sub check_can_change_field { # $PrivilegesRequired = 1 : the reporter, assignee or an empowered user; # $PrivilegesRequired = 2 : the assignee or an empowered user; # $PrivilegesRequired = 3 : an empowered user. - + # Only users in the time-tracking group can change time-tracking fields. if ( grep($_ eq $field, qw(deadline estimated_time remaining_time)) ) { - my $tt_group = Bugzilla->params->{timetrackinggroup}; - if (!$tt_group || !$user->in_group($tt_group)) { + if (!$user->is_timetracker) { $$PrivilegesRequired = 3; return 0; } @@ -3683,12 +3719,7 @@ sub check_can_change_field { } # *Only* users with (product-specific) "canconfirm" privs can confirm bugs. - if ($field eq 'canconfirm' - || ($field eq 'everconfirmed' && $newvalue) - || ($field eq 'bug_status' - && $oldvalue eq 'UNCONFIRMED' - && is_open_state($newvalue))) - { + if ($self->_changes_everconfirmed($field, $oldvalue, $newvalue)) { $$PrivilegesRequired = 3; return $user->in_group('canconfirm', $self->{'product_id'}); } @@ -3747,7 +3778,7 @@ sub check_can_change_field { } # - change the status from one open state to another if ($field eq 'bug_status' - && is_open_state($oldvalue) && is_open_state($newvalue)) + && is_open_state($oldvalue) && is_open_state($newvalue)) { $$PrivilegesRequired = 2; return 0; @@ -3764,6 +3795,24 @@ sub check_can_change_field { 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 # @@ -3858,8 +3907,7 @@ sub _validate_attribute { my @valid_attributes = ( # Miscellaneous properties and methods. qw(error groups product_id component_id - longdescs milestoneurl attachments - isopened isunconfirmed + comments milestoneurl attachments isopened flag_types num_attachment_flag_types show_attachment_flags any_flags_requesteeble), @@ -3871,40 +3919,40 @@ sub _validate_attribute { } sub AUTOLOAD { - use vars qw($AUTOLOAD); - my $attr = $AUTOLOAD; + use vars qw($AUTOLOAD); + my $attr = $AUTOLOAD; - $attr =~ s/.*:://; - return unless $attr=~ /[^A-Z]/; - if (!_validate_attribute($attr)) { - require Carp; - Carp::confess("invalid bug attribute $attr"); - } + $attr =~ s/.*:://; + return unless $attr=~ /[^A-Z]/; + if (!_validate_attribute($attr)) { + require Carp; + Carp::confess("invalid bug attribute $attr"); + } - no strict 'refs'; - *$AUTOLOAD = sub { - my $self = shift; + no strict 'refs'; + *$AUTOLOAD = sub { + my $self = shift; - return $self->{$attr} if defined $self->{$attr}; + return $self->{$attr} if defined $self->{$attr}; - $self->{_multi_selects} ||= [Bugzilla->get_fields( - {custom => 1, type => FIELD_TYPE_MULTI_SELECT })]; - if ( grep($_->name eq $attr, @{$self->{_multi_selects}}) ) { - # There is a bug in Perl 5.10.0, which is fixed in 5.10.1, - # which taints $attr at this point. trick_taint() can go - # away once we require 5.10.1 or newer. - trick_taint($attr); + $self->{_multi_selects} ||= [Bugzilla->get_fields( + {custom => 1, type => FIELD_TYPE_MULTI_SELECT })]; + if ( grep($_->name eq $attr, @{$self->{_multi_selects}}) ) { + # There is a bug in Perl 5.10.0, which is fixed in 5.10.1, + # which taints $attr at this point. trick_taint() can go + # away once we require 5.10.1 or newer. + trick_taint($attr); - $self->{$attr} ||= Bugzilla->dbh->selectcol_arrayref( - "SELECT value FROM bug_$attr WHERE bug_id = ? ORDER BY value", - undef, $self->id); - return $self->{$attr}; - } + $self->{$attr} ||= Bugzilla->dbh->selectcol_arrayref( + "SELECT value FROM bug_$attr WHERE bug_id = ? ORDER BY value", + undef, $self->id); + return $self->{$attr}; + } - return ''; - }; + return ''; + }; - goto &$AUTOLOAD; + goto &$AUTOLOAD; } 1; diff --git a/Bugzilla/BugMail.pm b/Bugzilla/BugMail.pm index 432c1a66b..31fb1ba54 100644 --- a/Bugzilla/BugMail.pm +++ b/Bugzilla/BugMail.pm @@ -110,7 +110,6 @@ sub three_columns { # roles when the email is sent. # All the names are email addresses, not userids # values are scalars, except for cc, which is a list -# This hash usually comes from the "mailrecipients" var in a template call. sub Send { my ($id, $forced) = (@_); @@ -121,6 +120,7 @@ sub Send { my $msg = ""; my $dbh = Bugzilla->dbh; + my $bug = new Bugzilla::Bug($id); # XXX - These variables below are useless. We could use field object # methods directly. But we first have to implement a cache in @@ -327,7 +327,7 @@ sub Send { } } - my ($comments, $anyprivate) = get_comments_by_bug($id, $start, $end); + my $comments = $bug->comments({ after => $start, to => $end }); ########################################################################### # Start of email filtering code @@ -388,6 +388,9 @@ sub Send { } } + Bugzilla::Hook::process('bugmail_recipients', + { bug => $bug, recipients => \%recipients }); + # Find all those user-watching anyone on the current list, who is not # on it already themselves. my $involved = join(",", keys %recipients); @@ -449,11 +452,6 @@ sub Send { # 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? - # If we are using insiders, and the comment is private, only send - # to insiders - my $insider_ok = 1; - $insider_ok = 0 if $anyprivate && !$user->is_insider; - # We shouldn't send mail if this is a dependency mail (i.e. there # is something in @depbugs), and any of the depending bugs are not # visible to the user. This is to avoid leaking the summaries of @@ -468,10 +466,7 @@ sub Send { # Make sure the user isn't in the nomail list, and the insider and # dep checks passed. - if ($user->email_enabled && - $insider_ok && - $dep_ok) - { + if ($user->email_enabled && $dep_ok) { # OK, OK, if we must. Email the user. $sent_mail = sendMail( user => $user, @@ -482,7 +477,6 @@ sub Send { fields => \%fielddescription, diffs => $diffs, newcomm => $comments, - anypriv => $anyprivate, isnew => !$start, id => $id, watch => exists $watching{$user_id} ? $watching{$user_id} : undef, @@ -508,14 +502,15 @@ sub sendMail { my %arguments = @_; my ($user, $hlRef, $relRef, $valueRef, $dmhRef, $fdRef, - $diffs, $newcomments, $anyprivate, $isnew, + $diffs, $comments_in, $isnew, $id, $watchingRef ) = @arguments{qw( user headers rels values defhead fields - diffs newcomm anypriv isnew + diffs newcomm isnew id watch )}; + my @send_comments = @$comments_in; my %values = %$valueRef; my @headerlist = @$hlRef; my %mailhead = %$dmhRef; @@ -539,7 +534,11 @@ sub sendMail $diffs = $new_diffs; - if (!@$diffs && !scalar(@$newcomments) && !$isnew) { + if (!$user->is_insider) { + @send_comments = grep { !$_->is_private } @send_comments; + } + + if (!@$diffs && !scalar(@send_comments) && !$isnew) { # Whoops, no differences! return 0; } @@ -598,7 +597,7 @@ sub sendMail reporter => $values{'reporter'}, reportername => Bugzilla::User->new({name => $values{'reporter'}})->name, diffs => $diffs, - new_comments => $newcomments, + new_comments => \@send_comments, threadingmarker => build_thread_marker($id, $user->id, $isnew), three_columns => \&three_columns, }; @@ -623,43 +622,4 @@ sub sendMail return 1; } -# Get bug comments for the given period. -sub get_comments_by_bug { - my ($id, $start, $end) = @_; - my $dbh = Bugzilla->dbh; - - my $result = ""; - my $count = 0; - my $anyprivate = 0; - - # $start will be undef for new bugs, and defined for pre-existing bugs. - if ($start) { - # If $start is not NULL, obtain the count-index - # of this comment for the leading "Comment #xxx" line. - $count = $dbh->selectrow_array('SELECT COUNT(*) FROM longdescs - WHERE bug_id = ? AND bug_when <= ?', - undef, ($id, $start)); - } - - my $raw = 0; # Do not format comments which are not of type CMT_NORMAL. - my $comments = Bugzilla::Bug::GetComments($id, "oldest_to_newest", $start, $end, $raw); - my $attach_base = correct_urlbase() . 'attachment.cgi?id='; - - foreach my $comment (@$comments) { - $comment->{count} = $count++; - # If an attachment was created, then add an URL. (Note: the 'g'lobal - # replace should work with comments with multiple attachments.) - if ($comment->{body} =~ /Created an attachment \(/) { - $comment->{body} =~ s/(Created an attachment \(id=([0-9]+)\))/$1\n --> \($attach_base$2\)/g; - } - $comment->{body} = $comment->{'already_wrapped'} ? $comment->{body} : wrap_comment($comment->{body}); - } - - if (Bugzilla->params->{'insidergroup'}) { - $anyprivate = 1 if scalar(grep {$_->{'isprivate'} > 0} @$comments); - } - - return ($comments, $anyprivate); -} - 1; diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm index 98e72a2ac..2830670b9 100644 --- a/Bugzilla/CGI.pm +++ b/Bugzilla/CGI.pm @@ -21,26 +21,27 @@ # Byron Jones # Marc Schumann +package Bugzilla::CGI; use strict; -package Bugzilla::CGI; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Util; + +use File::Basename; BEGIN { - if ($^O =~ /MSWin32/i) { + if (ON_WINDOWS) { # Help CGI find the correct temp directory as the default list # isn't Windows friendly (Bug 248988) $ENV{'TMPDIR'} = $ENV{'TEMP'} || $ENV{'TMP'} || "$ENV{'WINDIR'}\\TEMP"; } } -use CGI qw(-no_xhtml -oldstyle_urls :private_tempfiles :unique_headers SERVER_PUSH); - +use CGI qw(-no_xhtml -oldstyle_urls :private_tempfiles + :unique_headers SERVER_PUSH); use base qw(CGI); -use Bugzilla::Constants; -use Bugzilla::Error; -use Bugzilla::Util; - # We need to disable output buffering - see bug 179174 $| = 1; @@ -72,15 +73,9 @@ sub new { $self->charset(Bugzilla->params->{'utf8'} ? 'UTF-8' : ''); # Redirect to urlbase/sslbase if we are not viewing an attachment. - if (use_attachbase() && i_am_cgi()) { - my $cgi_file = $self->url('-path_info' => 0, '-query' => 0, '-relative' => 1); - $cgi_file =~ s/\?$//; - my $urlbase = Bugzilla->params->{'urlbase'}; - my $sslbase = Bugzilla->params->{'sslbase'}; - my $path_regexp = $sslbase ? qr/^(\Q$urlbase\E|\Q$sslbase\E)/ : qr/^\Q$urlbase\E/; - if ($cgi_file ne 'attachment.cgi' && $self->self_url !~ /$path_regexp/) { - $self->redirect_to_urlbase; - } + my $script = basename($0); + if ($self->url_is_attachment_base and $script ne 'attachment.cgi') { + $self->redirect_to_urlbase(); } # Check for errors @@ -122,6 +117,7 @@ sub parse_params { sub canonicalise_query { my ($self, @exclude) = @_; + $self->convert_old_params(); # Reconstruct the URL by concatenating the sorted param=value pairs my @parameters; foreach my $key (sort($self->param())) { @@ -146,6 +142,17 @@ sub canonicalise_query { return join("&", @parameters); } +sub convert_old_params { + my $self = shift; + + # bugidtype is now bug_id_type. + if ($self->param('bugidtype')) { + my $value = $self->param('bugidtype') eq 'exclude' ? 'nowords' : 'anyexact'; + $self->param('bug_id_type', $value); + $self->delete('bugidtype'); + } +} + sub clean_search_url { my $self = shift; # Delete any empty URL parameter. @@ -165,9 +172,6 @@ sub clean_search_url { } } - # Delete certain parameters if the associated parameter is empty. - $self->delete('bugidtype') if !$self->param('bug_id'); - # Delete leftovers from the login form $self->delete('Bugzilla_remember', 'GoAheadAndLogIn'); @@ -306,7 +310,7 @@ sub param { } 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. @@ -374,25 +378,26 @@ sub remove_cookie { '-value' => 'X'); } -# Redirect to https if required -sub require_https { - my ($self, $url) = @_; - # Do not create query string if data submitted via XMLRPC - # since we want the data to be resubmitted over POST method. - my $query = Bugzilla->usage_mode == USAGE_MODE_WEBSERVICE ? 0 : 1; - # XMLRPC clients (SOAP::Lite at least) requires 301 to redirect properly - # and do not work with 302. - my $status = Bugzilla->usage_mode == USAGE_MODE_WEBSERVICE ? 301 : 302; - if (defined $url) { - $url .= $self->url('-path_info' => 1, '-query' => $query, '-relative' => 1); - } else { - $url = $self->self_url; - $url =~ s/^http:/https:/i; - } - print $self->redirect(-location => $url, -status => $status); - # When using XML-RPC with mod_perl, we need the headers sent immediately. - $self->r->rflush if $ENV{MOD_PERL}; - 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. @@ -403,6 +408,61 @@ sub redirect_to_urlbase { 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->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; +} + # cookie() with UTF-8 support... sub cookie { @@ -518,13 +578,13 @@ effectively removing the cookie. As its only argument, it takes the name of the cookie to expire. -=item C +=item C -This routine redirects the client to a different location using the https protocol. -If the client is using XMLRPC, it will not retain the QUERY_STRING since XMLRPC uses POST. +This routine redirects the client to the https version of the page that +they're looking at, using the C parameter for the redirection. -It takes an optional argument which will be used as the base URL. If $baseurl -is not provided, the current URL is used. +Generally you should use L +instead of calling this directly. =item C diff --git a/Bugzilla/Classification.pm b/Bugzilla/Classification.pm index a7f59b4bb..322b5cf99 100644 --- a/Bugzilla/Classification.pm +++ b/Bugzilla/Classification.pm @@ -70,7 +70,7 @@ sub remove_from_db { $dbh->do("UPDATE products SET classification_id = 1 WHERE classification_id = ?", undef, $self->id); - $dbh->do("DELETE FROM classifications WHERE id = ?", undef, $self->id); + $self->SUPER::remove_from_db(); $dbh->bz_commit_transaction(); diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm new file mode 100644 index 000000000..cbdddba3c --- /dev/null +++ b/Bugzilla/Comment.pm @@ -0,0 +1,298 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is James Robson. +# Portions created by James Robson are Copyright (c) 2009 James Robson. +# All rights reserved. +# +# Contributor(s): James Robson + +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; + +############################### +#### Initialization #### +############################### + +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( + type + extra_data +); + +use constant DB_TABLE => 'longdescs'; +use constant ID_FIELD => 'comment_id'; +use constant LIST_ORDER => 'bug_when'; + +use constant VALIDATORS => { + type => \&_check_type, +}; + +use constant UPDATE_VALIDATORS => { + extra_data => \&_check_extra_data, +}; + +######################### +# Database Manipulation # +######################### + +sub update { + my $self = shift; + my $changes = $self->SUPER::update(@_); + $self->bug->_sync_fulltext(); + 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 { 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_extra_data { $_[0]->set('extra_data', $_[1]); } + +sub set_type { + my ($self, $type, $extra_data) = @_; + $self->set('type', $type); + $self->set_extra_data($extra_data); +} + +############## +# Validators # +############## + +sub _check_extra_data { + my ($invocant, $extra_data, $type) = @_; + $type = $invocant->type if ref $invocant; + if ($type == CMT_NORMAL or $type == CMT_POPULAR_VOTES) { + 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 }); + } + if ($type == CMT_MOVED_TO) { + $extra_data = Bugzilla::User->check($extra_data)->login; + } + 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; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Comment - A Comment for a given bug + +=head1 SYNOPSIS + + use Bugzilla::Comment; + + my $comment = Bugzilla::Comment->new($comment_id); + my $comments = Bugzilla::Comment->new_from_list($comment_ids); + +=head1 DESCRIPTION + +Bugzilla::Comment represents a comment attached to a bug. + +This implements all standard C methods. See +L for more details. + +=head2 Accessors + +=over + +=item C + +C The ID of the bug to which the comment belongs. + +=item C + +C The comment creation timestamp. + +=item C + +C The body without any special additional text. + +=item C + +C Time spent as related to this comment. + +=item C + +C Comment is marked as private + +=item C + +If this comment is stored in the database word-wrapped, this will be C<1>. +C<0> otherwise. + +=item C + +L who created the comment. + +=item C + +=over + +=item B + +C Body of the comment, including any special text (such as +"this bug was marked as a duplicate of..."). + +=item B + +=over + +=item C + +C. C<1> if this comment should be formatted specifically for +bugmail. + +=item C + +C. C<1> if the comment should be returned word-wrapped. + +=back + +=item B + +A string, the full text of the comment as it would be displayed to an end-user. + +=back + + + +=back + +=cut diff --git a/Bugzilla/Component.pm b/Bugzilla/Component.pm index 0bd2b1291..7148e4cef 100644 --- a/Bugzilla/Component.pm +++ b/Bugzilla/Component.pm @@ -65,6 +65,7 @@ use constant UPDATE_COLUMNS => qw( ); use constant VALIDATORS => { + create_series => \&Bugzilla::Object::check_boolean, product => \&_check_product, initialowner => \&_check_initialowner, initialqacontact => \&_check_initialqacontact, @@ -120,14 +121,15 @@ sub create { $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 $component = $class->insert_create_data($params); # We still have to fill the component_cc table. - $component->_update_cc_list($cc_list); + $component->_update_cc_list($cc_list) if $cc_list; # Create series for the new component. - $component->_create_series(); + $component->_create_series() if $create_series; $dbh->bz_commit_transaction(); return $component; diff --git a/Bugzilla/Config.pm b/Bugzilla/Config.pm index 469241794..f399900c7 100644 --- a/Bugzilla/Config.pm +++ b/Bugzilla/Config.pm @@ -35,6 +35,7 @@ use strict; use base qw(Exporter); use Bugzilla::Constants; use Bugzilla::Hook; +use Bugzilla::Install::Filesystem qw(fix_file_permissions); use Data::Dumper; use File::Temp; @@ -68,7 +69,7 @@ sub _load_params { } # This hook is also called in editparams.cgi. This call here is required # to make SetParam work. - Bugzilla::Hook::process('config-modify_panels', + Bugzilla::Hook::process('config_modify_panels', { panels => \%hook_panels }); } # END INIT CODE @@ -84,7 +85,7 @@ sub param_panels { $param_panels->{$module} = "Bugzilla::Config::$module" unless $module eq 'Common'; } # Now check for any hooked params - Bugzilla::Hook::process('config-add_panels', + Bugzilla::Hook::process('config_add_panels', { panel_modules => $param_panels }); return $param_panels; } @@ -151,10 +152,6 @@ sub update_params { { $param->{'makeproductgroups'} = $param->{'usebuggroups'}; } - if (exists $param->{'usebuggroupsentry'} - && !exists $param->{'useentrygroupdefault'}) { - $param->{'useentrygroupdefault'} = $param->{'usebuggroupsentry'}; - } # Modularise auth code if (exists $param->{'useLDAP'} && !exists $param->{'loginmethod'}) { @@ -196,6 +193,13 @@ sub update_params { $param->{'mail_delivery_method'} = $translation{$method}; } + # Convert the old "ssl" parameter to the new "ssl_redirect" parameter. + # Both "authenticated sessions" and "always" turn on "ssl_redirect" + # when upgrading. + if (exists $param->{'ssl'} and $param->{'ssl'} ne 'never') { + $param->{'ssl_redirect'} = 1; + } + # --- DEFAULTS FOR NEW PARAMS --- _load_params unless %params; @@ -203,7 +207,12 @@ sub update_params { my $name = $item->{'name'}; unless (exists $param->{$name}) { print "New parameter: $name\n" unless $new_install; - $param->{$name} = $answer->{$name} || $item->{'default'}; + if (exists $answer->{$name}) { + $param->{$name} = $answer->{$name}; + } + else { + $param->{$name} = $item->{'default'}; + } } } @@ -293,29 +302,13 @@ sub write_params { rename $tmpname, $param_file or die "Can't rename $tmpname to $param_file: $!"; - ChmodDataFile($param_file, 0666); + fix_file_permissions($param_file); # And now we have to reset the params cache so that Bugzilla will re-read # them. delete Bugzilla->request_cache->{params}; } -# Some files in the data directory must be world readable if and only if -# we don't have a webserver group. Call this function to do this. -# This will become a private function once all the datafile handling stuff -# moves into this package - -# This sub is not perldoc'd for that reason - noone should know about it -sub ChmodDataFile { - my ($file, $mask) = @_; - my $perm = 0770; - if ((stat(bz_locations()->{'datadir'}))[2] & 0002) { - $perm = 0777; - } - $perm = $perm & $mask; - chmod $perm,$file; -} - sub read_param_file { my %params; my $datadir = bz_locations()->{'datadir'}; diff --git a/Bugzilla/Config/Admin.pm b/Bugzilla/Config/Admin.pm index d4e822816..e6141cf9e 100644 --- a/Bugzilla/Config/Admin.pm +++ b/Bugzilla/Config/Admin.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::Admin::sortkey = "01"; +our $sortkey = 200; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/Advanced.pm b/Bugzilla/Config/Advanced.pm new file mode 100644 index 000000000..1acf76f38 --- /dev/null +++ b/Bugzilla/Config/Advanced.pm @@ -0,0 +1,57 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Netscape Communications +# Corporation. Portions created by Netscape are +# Copyright (C) 1998 Netscape Communications Corporation. All +# Rights Reserved. +# +# Contributor(s): Terry Weissman +# Dawn Endico +# Dan Mosedale +# Joe Robins +# Jacob Steenhagen +# J. Paul Reed +# Bradley Baetz +# Joseph Heenan +# Erik Stambaugh +# Frédéric Buclin +# Max Kanat-Alexander + +package Bugzilla::Config::Advanced; +use strict; + +our $sortkey = 1700; + +use constant get_param_list => ( + { + name => 'cookiedomain', + type => 't', + default => '' + }, + + { + name => 'inbound_proxies', + type => 't', + default => '' + }, + + { + name => 'proxy_url', + type => 't', + default => '' + }, +); + +1; diff --git a/Bugzilla/Config/Attachment.pm b/Bugzilla/Config/Attachment.pm index f8887bb26..ef61b818f 100644 --- a/Bugzilla/Config/Attachment.pm +++ b/Bugzilla/Config/Attachment.pm @@ -35,12 +35,12 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::Attachment::sortkey = "025"; +our $sortkey = 400; sub get_param_list { - my $class = shift; - my @param_list = ( - { + my $class = shift; + my @param_list = ( + { name => 'allow_attachment_display', type => 'b', default => 0 @@ -54,65 +54,56 @@ sub get_param_list { }, { - name => 'allow_attachment_deletion', - type => 'b', - default => 0 - }, + name => 'allow_attachment_deletion', + type => 'b', + default => 0 + }, + { + name => 'allow_attach_url', + type => 'b', + default => 0 + }, - { - name => 'allow_attach_url', - type => 'b', - default => 0 - }, + { + name => 'maxattachmentsize', + type => 't', + default => '1000', + checker => \&check_maxattachmentsize + }, - { - name => 'maxattachmentsize', - type => 't', - default => '1000', - checker => \&check_maxattachmentsize - }, + { + name => 'inline_attachment_mime', + type => 't', + default => '^text/|^image/', + }, - # The maximum size (in bytes) for patches and non-patch attachments. - # The default limit is 1000KB, which is 24KB less than mysql's default - # maximum packet size (which determines how much data can be sent in a - # single mysql packet and thus how much data can be inserted into the - # database) to provide breathing space for the data in other fields of - # the attachment record as well as any mysql packet overhead (I don't - # know of any, but I suspect there may be some.) + { + name => 'mime_types_file', + type => 't', + default => '', + }, - { - name => 'maxlocalattachment', - type => 't', - default => '0', - checker => \&check_numeric - }, + { + name => 'force_attach_bigfile', + type => 'b', + default => 0, + }, - { - name => 'convert_uncompressed_images', - type => 'b', - default => 0, - checker => \&check_image_converter - }, + # The maximum size (in bytes) for patches and non-patch attachments. + # The default limit is 1000KB, which is 24KB less than mysql's default + # maximum packet size (which determines how much data can be sent in a + # single mysql packet and thus how much data can be inserted into the + # database) to provide breathing space for the data in other fields of + # the attachment record as well as any mysql packet overhead (I don't + # know of any, but I suspect there may be some.) - { - name => 'inline_attachment_mime', - type => 't', - default => '^text/|^image/', - }, - - { - name => 'mime_types_file', - type => 't', - default => '', - }, - - { - name => 'force_attach_bigfile', - type => 'b', - default => 0, - }, - ); - return @param_list; + { + name => 'maxlocalattachment', + type => 't', + default => '0', + checker => \&check_numeric + } ); + return @param_list; } 1; diff --git a/Bugzilla/Config/Auth.pm b/Bugzilla/Config/Auth.pm index cbd94617a..c7d921ed5 100644 --- a/Bugzilla/Config/Auth.pm +++ b/Bugzilla/Config/Auth.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::Auth::sortkey = "02"; +our $sortkey = 300; sub get_param_list { my $class = shift; @@ -90,13 +90,6 @@ sub get_param_list { checker => \&check_multi }, - { - name => 'loginnetmask', - type => 't', - default => '0', - checker => \&check_netmask - }, - { name => 'requirelogin', type => 'b', diff --git a/Bugzilla/Config/BugChange.pm b/Bugzilla/Config/BugChange.pm index 0e518b689..4e197c5e9 100644 --- a/Bugzilla/Config/BugChange.pm +++ b/Bugzilla/Config/BugChange.pm @@ -36,7 +36,7 @@ use strict; use Bugzilla::Config::Common; use Bugzilla::Status; -$Bugzilla::Config::BugChange::sortkey = "03"; +our $sortkey = 500; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/BugFields.pm b/Bugzilla/Config/BugFields.pm index 43c7708df..e2ab1f605 100644 --- a/Bugzilla/Config/BugFields.pm +++ b/Bugzilla/Config/BugFields.pm @@ -36,7 +36,7 @@ use strict; use Bugzilla::Config::Common; use Bugzilla::Field; -$Bugzilla::Config::BugFields::sortkey = "04"; +our $sortkey = 600; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/BugMove.pm b/Bugzilla/Config/BugMove.pm index 87f6cbd73..2d973d8ca 100644 --- a/Bugzilla/Config/BugMove.pm +++ b/Bugzilla/Config/BugMove.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::BugMove::sortkey = "05"; +our $sortkey = 700; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/Common.pm b/Bugzilla/Config/Common.pm index b285b3bc9..7416b1794 100644 --- a/Bugzilla/Config/Common.pm +++ b/Bugzilla/Config/Common.pm @@ -34,6 +34,7 @@ package Bugzilla::Config::Common; use strict; +use Email::Address; use Socket; use Bugzilla::Util; @@ -47,10 +48,10 @@ use base qw(Exporter); qw(check_multi check_numeric check_regexp check_url check_group check_sslbase check_priority check_severity check_platform check_opsys check_shadowdb check_urlbase check_webdotbase - check_netmask check_user_verify_class check_image_converter + check_user_verify_class check_mail_delivery_method check_notification check_utf8 check_bug_status check_smtp_auth check_theschwartz_available - check_maxattachmentsize + check_maxattachmentsize check_email ); # Checking functions for the various values @@ -94,6 +95,14 @@ sub check_regexp { return $@; } +sub check_email { + my ($value) = @_; + if ($value !~ $Email::Address::mailbox) { + return "must be a valid email address."; + } + return ""; +} + sub check_sslbase { my $url = shift; if ($url ne '') { @@ -248,21 +257,6 @@ sub check_webdotbase { return ""; } -sub check_netmask { - my ($mask) = @_; - my $res = check_numeric($mask); - return $res if $res; - if ($mask < 0 || $mask > 32) { - return "an IPv4 netmask must be between 0 and 32 bits"; - } - # Note that if we changed the netmask from anything apart from 32, then - # existing logincookies which aren't for a single IP won't work - # any more. We can't know which ones they are, though, so they'll just - # take space until they're periodically cleared, later. - - return ""; -} - sub check_user_verify_class { # doeditparams traverses the list of params, and for each one it checks, # then updates. This means that if one param checker wants to look at @@ -272,41 +266,39 @@ sub check_user_verify_class { # the login method as LDAP, we won't notice, but all logins will fail. # So don't do that. + my $params = Bugzilla->params; my ($list, $entry) = @_; $list || return 'You need to specify at least one authentication mechanism'; for my $class (split /,\s*/, $list) { my $res = check_multi($class, $entry); return $res if $res; if ($class eq 'RADIUS') { - eval "require Authen::Radius"; - return "Error requiring Authen::Radius: '$@'" if $@; - return "RADIUS servername (RADIUS_server) is missing" unless Bugzilla->params->{"RADIUS_server"}; - return "RADIUS_secret is empty" unless Bugzilla->params->{"RADIUS_secret"}; + if (!Bugzilla->feature('auth_radius')) { + return "RADIUS support is not available. Run checksetup.pl" + . " for more details"; + } + return "RADIUS servername (RADIUS_server) is missing" + if !$params->{"RADIUS_server"}; + return "RADIUS_secret is empty" if !$params->{"RADIUS_secret"}; } elsif ($class eq 'LDAP') { - eval "require Net::LDAP"; - return "Error requiring Net::LDAP: '$@'" if $@; - return "LDAP servername (LDAPserver) is missing" unless Bugzilla->params->{"LDAPserver"}; - return "LDAPBaseDN is empty" unless Bugzilla->params->{"LDAPBaseDN"}; + if (!Bugzilla->feature('auth_ldap')) { + return "LDAP support is not available. Run checksetup.pl" + . " for more details"; + } + return "LDAP servername (LDAPserver) is missing" + if !$params->{"LDAPserver"}; + return "LDAPBaseDN is empty" if !$params->{"LDAPBaseDN"}; } } return ""; } -sub check_image_converter { - my ($value, $hash) = @_; - if ($value == 1){ - eval "require Image::Magick"; - return "Error requiring Image::Magick: '$@'" if $@; - } - return ""; -} - sub check_mail_delivery_method { my $check = check_multi(@_); return $check if $check; my $mailer = shift; - if ($mailer eq 'sendmail' && $^O =~ /MSWin32/i) { + if ($mailer eq 'sendmail' and ON_WINDOWS) { # look for sendmail.exe return "Failed to locate " . SENDMAIL_EXE unless -e SENDMAIL_EXE; @@ -342,20 +334,25 @@ sub check_notification { "about the next stable release, you should select " . "'latest_stable_release' instead"; } + if ($option ne 'disabled' && !Bugzilla->feature('updates')) { + return "Some Perl modules are missing to get notifications about " . + "new releases. See the output of checksetup.pl for more information"; + } return ""; } sub check_smtp_auth { my $username = shift; - if ($username) { - eval "require Authen::SASL"; - return "Error requiring Authen::SASL: '$@'" if $@; + if ($username and !Bugzilla->feature('smtp_auth')) { + return "SMTP Authentication is not available. Run checksetup.pl for" + . " more details"; } return ""; } sub check_theschwartz_available { - if (!eval { require TheSchwartz; require Daemon::Generic; }) { + my $use_queue = shift; + if ($use_queue && !Bugzilla->feature('jobqueue')) { return "Using the job queue requires that you have certain Perl" . " modules installed. See the output of checksetup.pl" . " for more information"; diff --git a/Bugzilla/Config/Core.pm b/Bugzilla/Config/Core.pm index 7054bd893..1777bb7b1 100644 --- a/Bugzilla/Config/Core.pm +++ b/Bugzilla/Config/Core.pm @@ -35,17 +35,9 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::Core::sortkey = "00"; - -sub get_param_list { - my $class = shift; - my @param_list = ( - { - name => 'maintainer', - type => 't', - default => 'THE MAINTAINER HAS NOT YET BEEN SET' - }, +our $sortkey = 100; +use constant get_param_list => ( { name => 'error_log', type => 't', @@ -72,10 +64,9 @@ sub get_param_list { }, { - name => 'docs_urlbase', - type => 't', - default => 'docs/%lang%/html/', - checker => \&check_url + name => 'ssl_redirect', + type => 'b', + default => 0 }, { @@ -85,59 +76,11 @@ sub get_param_list { checker => \&check_sslbase }, - { - name => 'ssl', - type => 's', - choices => ['never', 'authenticated sessions', 'always'], - default => 'never' - }, - - { - name => 'cookiedomain', - type => 't', - default => '' - }, - { name => 'cookiepath', type => 't', default => '/' }, - - { - name => 'utf8', - type => 'b', - default => '0', - checker => \&check_utf8 - }, - - { - name => 'shutdownhtml', - type => 'l', - default => '' - }, - - { - name => 'announcehtml', - type => 'l', - default => '' - }, - - { - name => 'proxy_url', - type => 't', - default => '' - }, - - { - name => 'upgrade_notification', - type => 's', - choices => ['development_snapshot', 'latest_stable_release', - 'stable_branch_release', 'disabled'], - default => 'latest_stable_release', - checker => \&check_notification - } ); - return @param_list; -} +); 1; diff --git a/Bugzilla/Config/DependencyGraph.pm b/Bugzilla/Config/DependencyGraph.pm index 37c07f3d3..f717b2708 100644 --- a/Bugzilla/Config/DependencyGraph.pm +++ b/Bugzilla/Config/DependencyGraph.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::DependencyGraph::sortkey = "06"; +our $sortkey = 800; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/General.pm b/Bugzilla/Config/General.pm new file mode 100644 index 000000000..0f043548b --- /dev/null +++ b/Bugzilla/Config/General.pm @@ -0,0 +1,83 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Netscape Communications +# Corporation. Portions created by Netscape are +# Copyright (C) 1998 Netscape Communications Corporation. All +# Rights Reserved. +# +# Contributor(s): Terry Weissman +# Dawn Endico +# Dan Mosedale +# Joe Robins +# Jacob Steenhagen +# J. Paul Reed +# Bradley Baetz +# Joseph Heenan +# Erik Stambaugh +# Frédéric Buclin +# Max Kanat-Alexander + +package Bugzilla::Config::General; +use strict; +use Bugzilla::Config::Common; + +our $sortkey = 150; + +use constant get_param_list => ( + { + name => 'maintainer', + type => 't', + no_reset => '1', + default => '', + checker => \&check_email + }, + + { + name => 'docs_urlbase', + type => 't', + default => 'docs/%lang%/html/', + checker => \&check_url + }, + + { + name => 'utf8', + type => 'b', + default => '0', + checker => \&check_utf8 + }, + + { + name => 'shutdownhtml', + type => 'l', + default => '' + }, + + { + name => 'announcehtml', + type => 'l', + default => '' + }, + + { + name => 'upgrade_notification', + type => 's', + choices => ['development_snapshot', 'latest_stable_release', + 'stable_branch_release', 'disabled'], + default => 'latest_stable_release', + checker => \&check_notification + }, +); + +1; diff --git a/Bugzilla/Config/GroupSecurity.pm b/Bugzilla/Config/GroupSecurity.pm index 0a238f209..f7f717379 100644 --- a/Bugzilla/Config/GroupSecurity.pm +++ b/Bugzilla/Config/GroupSecurity.pm @@ -36,7 +36,7 @@ use strict; use Bugzilla::Config::Common; use Bugzilla::Group; -$Bugzilla::Config::GroupSecurity::sortkey = "07"; +our $sortkey = 900; sub get_param_list { my $class = shift; @@ -48,12 +48,6 @@ sub get_param_list { default => 0 }, - { - name => 'useentrygroupdefault', - type => 'b', - default => 0 - }, - { name => 'chartgroup', type => 's', diff --git a/Bugzilla/Config/LDAP.pm b/Bugzilla/Config/LDAP.pm index a9b46382e..e47f92308 100644 --- a/Bugzilla/Config/LDAP.pm +++ b/Bugzilla/Config/LDAP.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::LDAP::sortkey = "09"; +our $sortkey = 1000; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/MTA.pm b/Bugzilla/Config/MTA.pm index c7843e286..b1e3ab1a4 100644 --- a/Bugzilla/Config/MTA.pm +++ b/Bugzilla/Config/MTA.pm @@ -36,7 +36,7 @@ use strict; use Bugzilla::Config::Common; use Email::Send; -$Bugzilla::Config::MTA::sortkey = "10"; +our $sortkey = 1200; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/PatchViewer.pm b/Bugzilla/Config/PatchViewer.pm index 8de04ef76..6bd9557a9 100644 --- a/Bugzilla/Config/PatchViewer.pm +++ b/Bugzilla/Config/PatchViewer.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::PatchViewer::sortkey = "11"; +our $sortkey = 1300; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/Query.pm b/Bugzilla/Config/Query.pm index fbfdb4c22..808a9a102 100644 --- a/Bugzilla/Config/Query.pm +++ b/Bugzilla/Config/Query.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::Query::sortkey = "12"; +our $sortkey = 1400; sub get_param_list { my $class = shift; @@ -67,13 +67,6 @@ sub get_param_list { default => 'bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&emailassigned_to1=1&emailassigned_to2=1&emailreporter2=1&emailcc2=1&emailqa_contact2=1&order=Importance&long_desc_type=substring' }, - { - name => 'quicksearch_comment_cutoff', - type => 't', - default => '4', - checker => \&check_numeric - }, - { name => 'specific_search_allow_empty_words', type => 'b', diff --git a/Bugzilla/Config/RADIUS.pm b/Bugzilla/Config/RADIUS.pm index 6701d6f08..bc072a9c4 100644 --- a/Bugzilla/Config/RADIUS.pm +++ b/Bugzilla/Config/RADIUS.pm @@ -25,7 +25,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::RADIUS::sortkey = "09"; +our $sortkey = 1100; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/ShadowDB.pm b/Bugzilla/Config/ShadowDB.pm index f9af4fb6d..a605b2363 100644 --- a/Bugzilla/Config/ShadowDB.pm +++ b/Bugzilla/Config/ShadowDB.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::ShadowDB::sortkey = "13"; +our $sortkey = 1500; sub get_param_list { my $class = shift; diff --git a/Bugzilla/Config/UserMatch.pm b/Bugzilla/Config/UserMatch.pm index 1d76a515b..cc7289c1f 100644 --- a/Bugzilla/Config/UserMatch.pm +++ b/Bugzilla/Config/UserMatch.pm @@ -35,7 +35,7 @@ use strict; use Bugzilla::Config::Common; -$Bugzilla::Config::UserMatch::sortkey = "14"; +our $sortkey = 1600; sub get_param_list { my $class = shift; @@ -46,13 +46,6 @@ sub get_param_list { default => '0' }, - { - name => 'usermatchmode', - type => 's', - choices => ['off', 'wildcard', 'search'], - default => 'off' - }, - { name => 'maxusermatches', type => 't', diff --git a/Bugzilla/Constants.pm b/Bugzilla/Constants.pm index f22e31933..147aa7f80 100644 --- a/Bugzilla/Constants.pm +++ b/Bugzilla/Constants.pm @@ -55,9 +55,9 @@ use File::Basename; AUTH_LOGINFAILED AUTH_DISABLED AUTH_NO_SUCH_USER + AUTH_LOCKOUT USER_PASSWORD_MIN_LENGTH - USER_PASSWORD_MAX_LENGTH LOGIN_OPTIONAL LOGIN_NORMAL @@ -79,6 +79,7 @@ use File::Basename; DEFAULT_COLUMN_LIST DEFAULT_QUERY_NAME + DEFAULT_MILESTONE QUERY_LIST LIST_OF_BUGS @@ -92,6 +93,8 @@ use File::Basename; CMT_HAS_DUPE CMT_POPULAR_VOTES CMT_MOVED_TO + CMT_ATTACHMENT_CREATED + CMT_ATTACHMENT_UPDATED THROW_ERROR @@ -103,7 +106,7 @@ use File::Basename; EVT_OTHER EVT_ADDED_REMOVED EVT_COMMENT EVT_ATTACHMENT EVT_ATTACHMENT_DATA EVT_PROJ_MANAGEMENT EVT_OPENED_CLOSED EVT_KEYWORD EVT_CC EVT_DEPEND_BLOCK EVT_BUG_CREATED - + NEG_EVENTS EVT_UNCONFIRMED EVT_CHANGED_BY_ME @@ -127,14 +130,18 @@ use File::Basename; FIELD_TYPE_BUG_ID FIELD_TYPE_BUG_URLS + TIMETRACKING_FIELDS + USAGE_MODE_BROWSER USAGE_MODE_CMDLINE - USAGE_MODE_WEBSERVICE + USAGE_MODE_XMLRPC USAGE_MODE_EMAIL + USAGE_MODE_JSON ERROR_MODE_WEBPAGE ERROR_MODE_DIE ERROR_MODE_DIE_SOAP_FAULT + ERROR_MODE_JSON_RPC ERROR_MODE_AJAX INSTALLATION_MODE_INTERACTIVE @@ -146,8 +153,11 @@ use File::Basename; MAX_TOKEN_AGE MAX_LOGINCOOKIE_AGE + MAX_LOGIN_ATTEMPTS + LOGIN_LOCKOUT_INTERVAL SAFE_PROTOCOLS + LEGAL_CONTENT_TYPES MIN_SMALLINT MAX_SMALLINT @@ -172,7 +182,7 @@ use File::Basename; # CONSTANTS # # Bugzilla version -use constant BUGZILLA_VERSION => "3.4.6"; +use constant BUGZILLA_VERSION => "3.6"; # These are unique values that are unlikely to match a string or a number, # to be used in criteria for match() functions and other things. They start @@ -225,10 +235,10 @@ use constant AUTH_ERROR => 2; use constant AUTH_LOGINFAILED => 3; use constant AUTH_DISABLED => 4; use constant AUTH_NO_SUCH_USER => 5; +use constant AUTH_LOCKOUT => 6; -# The minimum and maximum lengths a password must have. -use constant USER_PASSWORD_MIN_LENGTH => 3; -use constant USER_PASSWORD_MAX_LENGTH => 16; +# The minimum length a password must have. +use constant USER_PASSWORD_MIN_LENGTH => 6; use constant LOGIN_OPTIONAL => 0; use constant LOGIN_NORMAL => 1; @@ -238,18 +248,6 @@ use constant LOGOUT_ALL => 0; use constant LOGOUT_CURRENT => 1; use constant LOGOUT_KEEP_CURRENT => 2; -use constant contenttypes => - { - "html"=> "text/html" , - "rdf" => "application/rdf+xml" , - "atom"=> "application/atom+xml" , - "xml" => "application/xml" , - "js" => "application/x-javascript" , - "csv" => "text/csv" , - "png" => "image/png" , - "ics" => "text/calendar" , - }; - use constant GRANT_DIRECT => 0; use constant GRANT_REGEXP => 2; @@ -270,6 +268,9 @@ use constant DEFAULT_COLUMN_LIST => ( # for the default settings. use constant DEFAULT_QUERY_NAME => '(Default query)'; +# The default "defaultmilestone" created for products. +use constant DEFAULT_MILESTONE => '---'; + # The possible types for saved searches. use constant QUERY_LIST => 0; use constant LIST_OF_BUGS => 1; @@ -286,6 +287,8 @@ use constant CMT_DUPE_OF => 1; use constant CMT_HAS_DUPE => 2; use constant CMT_POPULAR_VOTES => 3; use constant CMT_MOVED_TO => 4; +use constant CMT_ATTACHMENT_CREATED => 5; +use constant CMT_ATTACHMENT_UPDATED => 6; # Determine whether a validation routine should return 0 or throw # an error when the validation fails. @@ -370,28 +373,58 @@ use constant FIELD_TYPE_DATETIME => 5; use constant FIELD_TYPE_BUG_ID => 6; use constant FIELD_TYPE_BUG_URLS => 7; +# The fields from fielddefs that are blocked from non-timetracking users. +# work_time is sometimes called actual_time. +use constant TIMETRACKING_FIELDS => + qw(estimated_time remaining_time work_time actual_time + percentage_complete deadline); + # The maximum number of days a token will remain valid. use constant MAX_TOKEN_AGE => 3; # How many days a logincookie will remain valid if not used. use constant MAX_LOGINCOOKIE_AGE => 30; +# Maximum failed logins to lock account for this IP +use constant MAX_LOGIN_ATTEMPTS => 5; +# If the maximum login attempts occur during this many minutes, the +# account is locked. +use constant LOGIN_LOCKOUT_INTERVAL => 30; + # Protocols which are considered as safe. use constant SAFE_PROTOCOLS => ('afs', 'cid', 'ftp', 'gopher', 'http', 'https', 'irc', 'mid', 'news', 'nntp', 'prospero', 'telnet', 'view-source', 'wais'); +# Valid MIME types for attachments. +use constant LEGAL_CONTENT_TYPES => ('application', 'audio', 'image', 'message', + 'model', 'multipart', 'text', 'video'); + +use constant contenttypes => + { + "html"=> "text/html" , + "rdf" => "application/rdf+xml" , + "atom"=> "application/atom+xml" , + "xml" => "application/xml" , + "js" => "application/x-javascript" , + "csv" => "text/csv" , + "png" => "image/png" , + "ics" => "text/calendar" , + }; + # Usage modes. Default USAGE_MODE_BROWSER. Use with Bugzilla->usage_mode. use constant USAGE_MODE_BROWSER => 0; use constant USAGE_MODE_CMDLINE => 1; -use constant USAGE_MODE_WEBSERVICE => 2; +use constant USAGE_MODE_XMLRPC => 2; use constant USAGE_MODE_EMAIL => 3; +use constant USAGE_MODE_JSON => 4; # Error modes. Default set by Bugzilla->usage_mode (so ERROR_MODE_WEBPAGE # usually). Use with Bugzilla->error_mode. use constant ERROR_MODE_WEBPAGE => 0; use constant ERROR_MODE_DIE => 1; use constant ERROR_MODE_DIE_SOAP_FAULT => 2; -use constant ERROR_MODE_AJAX => 3; +use constant ERROR_MODE_JSON_RPC => 3; +use constant ERROR_MODE_AJAX => 4; # The various modes that checksetup.pl can run in. use constant INSTALLATION_MODE_INTERACTIVE => 0; @@ -425,13 +458,13 @@ use constant DB_MODULE => { name => 'Oracle'}, }; -# The user who should be considered "root" when we're giving -# instructions to Bugzilla administrators. -use constant ROOT_USER => $^O =~ /MSWin32/i ? 'Administrator' : 'root'; - # True if we're on Win32. use constant ON_WINDOWS => ($^O =~ /MSWin32/i); +# The user who should be considered "root" when we're giving +# instructions to Bugzilla administrators. +use constant ROOT_USER => ON_WINDOWS ? 'Administrator' : 'root'; + use constant MIN_SMALLINT => -32768; use constant MAX_SMALLINT => 32767; diff --git a/Bugzilla/DB.pm b/Bugzilla/DB.pm index 238532155..830d2835e 100644 --- a/Bugzilla/DB.pm +++ b/Bugzilla/DB.pm @@ -65,7 +65,7 @@ use constant ISOLATION_LEVEL => 'REPEATABLE READ'; use constant ENUM_DEFAULTS => { bug_severity => ['blocker', 'critical', 'major', 'normal', 'minor', 'trivial', 'enhancement'], - priority => ["P1","P2","P3","P4","P5"], + priority => ["Highest", "High", "Normal", "Low", "Lowest", "---"], op_sys => ["All","Windows","Mac OS","Linux","Other"], rep_platform => ["All","PC","Macintosh","Other"], bug_status => ["UNCONFIRMED","NEW","ASSIGNED","REOPENED","RESOLVED", @@ -271,9 +271,9 @@ EOT } # List of abstract methods we are checking the derived class implements -our @_abstract_methods = qw(REQUIRED_VERSION PROGRAM_NAME DBD_VERSION - new sql_regexp sql_not_regexp sql_limit sql_to_days - sql_date_format sql_interval bz_explain); +our @_abstract_methods = qw(new sql_regexp sql_not_regexp sql_limit sql_to_days + sql_date_format sql_interval bz_explain + sql_group_concat); # This overridden import method will check implementation of inherited classes # for missing implementation of abstract methods @@ -286,7 +286,7 @@ sub import { # make sure all abstract methods are implemented foreach my $meth (@_abstract_methods) { $pkg->can($meth) - or croak("Class $pkg does not define method $meth"); + or die("Class $pkg does not define method $meth"); } } @@ -537,6 +537,13 @@ sub bz_alter_column { ThrowCodeError('column_not_null_no_default_alter', { name => "$table.$name" }) if ($any_nulls); } + # Preserve foreign key definitions in the Schema object when altering + # types. + if (defined $current_def->{REFERENCES}) { + # Make sure we don't modify the caller's $new_def. + $new_def = dclone($new_def); + $new_def->{REFERENCES} = $current_def->{REFERENCES}; + } $self->bz_alter_column_raw($table, $name, $new_def, $current_def, $set_nulls_to); $self->_bz_real_schema->set_column($table, $name, $new_def); @@ -689,7 +696,7 @@ sub bz_add_field_tables { if ($field->type == FIELD_TYPE_MULTI_SELECT) { my $ms_table = "bug_" . $field->name; $self->_bz_add_field_table($ms_table, - $self->_bz_schema->MULTI_SELECT_VALUE_TABLE); + $self->_bz_schema->MULTI_SELECT_VALUE_TABLE); $self->bz_add_fk($ms_table, 'bug_id', {TABLE => 'bugs', COLUMN => 'bug_id', @@ -830,6 +837,14 @@ sub bz_drop_table { } } +sub bz_fk_info { + my ($self, $table, $column) = @_; + my $col_info = $self->bz_column_info($table, $column); + return undef if !$col_info; + my $fk = $col_info->{REFERENCES}; + return $fk; +} + sub bz_rename_column { my ($self, $table, $old_name, $new_name) = @_; @@ -872,6 +887,16 @@ sub bz_rename_table { $self->_bz_store_real_schema; } +sub bz_set_next_serial_value { + my ($self, $table, $column, $value) = @_; + if (!$value) { + $value = $self->selectrow_array("SELECT MAX($column) FROM $table") || 0; + $value++; + } + my @sql = $self->_bz_real_schema->get_set_serial_sql($table, $column, $value); + $self->do($_) foreach @sql; +} + ##################################################################### # Schema Information Methods ##################################################################### @@ -1001,7 +1026,7 @@ sub bz_start_transaction { sub bz_commit_transaction { my ($self) = @_; - + if ($self->{private_bz_transaction_count} > 1) { $self->{private_bz_transaction_count}--; } elsif ($self->bz_in_transaction) { diff --git a/Bugzilla/DB/Mysql.pm b/Bugzilla/DB/Mysql.pm index 451c4223e..d9e5462d0 100644 --- a/Bugzilla/DB/Mysql.pm +++ b/Bugzilla/DB/Mysql.pm @@ -68,9 +68,13 @@ sub new { $dsn .= ";port=$port" if $port; $dsn .= ";mysql_socket=$sock" if $sock; - my $attrs = { mysql_enable_utf8 => Bugzilla->params->{'utf8'} }; + my %attrs = ( + mysql_enable_utf8 => Bugzilla->params->{'utf8'}, + # Needs to be explicitly specified for command-line processes. + mysql_auto_reconnect => 1, + ); - my $self = $class->db_new($dsn, $user, $pass, $attrs); + my $self = $class->db_new($dsn, $user, $pass, \%attrs); # This makes sure that if the tables are encoded as UTF-8, we # return their data correctly. @@ -158,15 +162,15 @@ sub sql_limit { sub sql_string_concat { my ($self, @params) = @_; - + return 'CONCAT(' . join(', ', @params) . ')'; } sub sql_fulltext_search { my ($self, $column, $text) = @_; - # quote un-quoted compound words - my @words = quotewords('[\s()]+', 'delimiters', $text); + # quote un-quoted compound words + my @words = quotewords('[\s()]+', 'delimiters', $text); if ($text =~ /(?:^|\W)[+\-<>~"()]/ || $text =~ /[()"*](?:$|\W)/) { # already a boolean mode search @@ -200,7 +204,7 @@ sub sql_fulltext_search { sub sql_istring { my ($self, $string) = @_; - + return $string; } @@ -324,15 +328,11 @@ EOT } - # Figure out if any existing tables are of type ISAM and convert them - # to type MyISAM if so. ISAM tables are deprecated in MySQL 3.23, - # which Bugzilla now requires, and they don't support more than 16 - # indexes per table, which Bugzilla needs. - my $table_status = $self->selectall_arrayref("SHOW TABLE STATUS"); + my %table_status = @{ $self->selectcol_arrayref("SHOW TABLE STATUS", + {Columns=>[1,2]}) }; my @isam_tables; - foreach my $row (@$table_status) { - my ($name, $type) = @$row; - push(@isam_tables, $name) if (defined($type) && $type eq "ISAM"); + foreach my $name (keys %table_status) { + push(@isam_tables, $name) if (defined($table_status{$name}) && $table_status{$name} eq "ISAM"); } if(scalar(@isam_tables)) { @@ -354,7 +354,9 @@ EOT # We want to convert tables to InnoDB, but it's possible that they have # fulltext indexes on them, and conversion will fail unless we remove # the indexes. - if (grep($_ eq 'bugs', @tables)) { + if (grep($_ eq 'bugs', @tables) + and !grep($_ eq 'bugs_fulltext', @tables)) + { if ($self->bz_index_info_real('bugs', 'short_desc')) { $self->bz_drop_index_raw('bugs', 'short_desc'); } @@ -363,7 +365,9 @@ EOT $sd_index_deleted = 1; # Used for later schema cleanup. } } - if (grep($_ eq 'longdescs', @tables)) { + if (grep($_ eq 'longdescs', @tables) + and !grep($_ eq 'bugs_fulltext', @tables)) + { if ($self->bz_index_info_real('longdescs', 'thetext')) { $self->bz_drop_index_raw('longdescs', 'thetext'); } @@ -375,9 +379,9 @@ EOT # Upgrade tables from MyISAM to InnoDB my @myisam_tables; - foreach my $row (@$table_status) { - my ($name, $type) = @$row; - if (defined ($type) && $type =~ /^MYISAM$/i + foreach my $name (keys %table_status) { + if (defined($table_status{$name}) + && $table_status{$name} =~ /^MYISAM$/i && !grep($_ eq $name, Bugzilla::DB::Schema::Mysql::MYISAM_TABLES)) { push(@myisam_tables, $name) ; @@ -724,6 +728,7 @@ EOT foreach my $table ($self->bz_table_list_real) { my $info_sth = $self->prepare("SHOW FULL COLUMNS FROM $table"); $info_sth->execute(); + my (@binary_sql, @utf8_sql); while (my $column = $info_sth->fetchrow_hashref) { # Our conversion code doesn't work on enum fields, but they # all go away later in checksetup anyway. @@ -736,34 +741,13 @@ EOT { my $name = $column->{Field}; - # The code below doesn't work on a field with a FULLTEXT - # index. So we drop it, which we'd do later anyway. - if ($table eq 'longdescs' && $name eq 'thetext') { - $self->bz_drop_index('longdescs', - 'longdescs_thetext_idx'); - } - if ($table eq 'bugs' && $name eq 'short_desc') { - $self->bz_drop_index('bugs', 'bugs_short_desc_idx'); - } - my %ft_indexes; - if ($table eq 'bugs_fulltext') { - %ft_indexes = $self->_bz_real_schema->get_indexes_on_column_abstract( - 'bugs_fulltext', $name); - foreach my $index (keys %ft_indexes) { - $self->bz_drop_index('bugs_fulltext', $index); - } - } - if ($table eq 'test_runs' && $name eq 'summary') { - $self->bz_drop_index('test_runs', 'test_runs_summary_idx'); - } + print "$table.$name needs to be converted to UTF-8...\n"; my $dropped = $self->bz_drop_related_fks($table, $name); push(@dropped_fks, @$dropped); - print "Converting $table.$name to be stored as UTF-8...\n"; - my $col_info = + my $col_info = $self->bz_column_info_real($table, $name); - # CHANGE COLUMN doesn't take PRIMARY KEY delete $col_info->{PRIMARYKEY}; my $sql_def = $self->_bz_schema->get_type_ddl($col_info); @@ -777,21 +761,41 @@ EOT my $type = $self->_bz_schema->convert_type($col_info->{TYPE}); $binary =~ s/(\Q$type\E)/$1 CHARACTER SET binary/; $utf8 =~ s/(\Q$type\E)/$1 CHARACTER SET utf8/; - $self->do("ALTER TABLE $table CHANGE COLUMN $name $name - $binary"); - $self->do("ALTER TABLE $table CHANGE COLUMN $name $name - $utf8"); + push(@binary_sql, "MODIFY COLUMN $name $binary"); + push(@utf8_sql, "MODIFY COLUMN $name $utf8"); + } + } # foreach column - if ($table eq 'bugs_fulltext') { - foreach my $index (keys %ft_indexes) { - $self->bz_add_index('bugs_fulltext', $index, - $ft_indexes{$index}); - } + if (@binary_sql) { + my %indexes = %{ $self->bz_table_indexes($table) }; + foreach my $index_name (keys %indexes) { + my $index = $indexes{$index_name}; + if ($index->{TYPE} and $index->{TYPE} eq 'FULLTEXT') { + $self->bz_drop_index($table, $index_name); + } + else { + delete $indexes{$index_name}; + } + if ($table eq 'test_runs' && $index_name eq 'summary') { + $self->bz_drop_index('test_runs', 'test_runs_summary_idx'); } } - } - $self->do("ALTER TABLE $table DEFAULT CHARACTER SET utf8"); + print "Converting the $table table to UTF-8...\n"; + my $bin = "ALTER TABLE $table " . join(', ', @binary_sql); + my $utf = "ALTER TABLE $table " . join(', ', @utf8_sql, + 'DEFAULT CHARACTER SET utf8'); + $self->do($bin); + $self->do($utf); + + # Re-add any removed FULLTEXT indexes. + foreach my $index (keys %indexes) { + $self->bz_add_index($table, $index, $indexes{$index}); + } + } + else { + $self->do("ALTER TABLE $table DEFAULT CHARACTER SET utf8"); + } } # foreach my $table (@tables) @@ -832,22 +836,40 @@ sub _fix_defaults { # a default. return unless (defined $assi_default && $assi_default ne ''); + my %fix_columns; foreach my $table ($self->_bz_real_schema->get_table_list()) { foreach my $column ($self->bz_table_columns($table)) { - my $abs_def = $self->bz_column_info($table, $column); + my $abs_def = $self->bz_column_info($table, $column); + # BLOB/TEXT columns never have defaults + next if $abs_def->{TYPE} =~ /BLOB|TEXT/i; if (!defined $abs_def->{DEFAULT}) { # Get the exact default from the database without any # "fixing" by bz_column_info_real. my $raw_info = $self->_bz_raw_column_info($table, $column); my $raw_default = $raw_info->{COLUMN_DEF}; if (defined $raw_default) { - $self->bz_alter_column_raw($table, $column, $abs_def); - $raw_default = "''" if $raw_default eq ''; - print "Removed incorrect DB default: $raw_default\n"; + if ($raw_default eq '') { + # Only (var)char columns can have empty strings as + # defaults, so if we got an empty string for some + # other default type, then it's bogus. + next unless $abs_def->{TYPE} =~ /char/i; + $raw_default = "''"; + } + $fix_columns{$table} ||= []; + push(@{ $fix_columns{$table} }, $column); + print "$table.$column has incorrect DB default: $raw_default\n"; } } } # foreach $column } # foreach $table + + print "Fixing defaults...\n"; + foreach my $table (reverse sort keys %fix_columns) { + my @alters = map("ALTER COLUMN $_ DROP DEFAULT", + @{ $fix_columns{$table} }); + my $sql = "ALTER TABLE $table " . join(',', @alters); + $self->do($sql); + } } # There is a bug in MySQL 4.1.0 - 4.1.15 that makes certain SELECT diff --git a/Bugzilla/DB/Oracle.pm b/Bugzilla/DB/Oracle.pm index a2c78e094..5dd127882 100644 --- a/Bugzilla/DB/Oracle.pm +++ b/Bugzilla/DB/Oracle.pm @@ -115,6 +115,12 @@ sub bz_explain { return join("\n", @$explain); } +sub sql_group_concat { + my ($self, $text, $separator) = @_; + $separator ||= "','"; + return "group_concat(T_CLOB_DELIM($text, $separator))"; +} + sub sql_regexp { my ($self, $expr, $pattern, $nocheck, $real_pattern) = @_; $real_pattern ||= $pattern; @@ -271,6 +277,10 @@ sub _fix_hashref { sub adjust_statement { my ($sql) = @_; + + if ($sql =~ /^CREATE OR REPLACE.*/i){ + return $sql; + } # We can't just assume any occurrence of "''" in $sql is an empty # string, since "''" can occur inside a string literal as a way of @@ -337,6 +347,10 @@ sub adjust_statement { # Oracle need no 'AS' $nonstring =~ s/\bAS\b//ig; + + # Take the first 4000 chars for comparison + $nonstring =~ s/\(\s*(longdescs_\d+\.thetext|attachdata_\d+\.thedata)/ + \(DBMS_LOB.SUBSTR\($1, 4000, 1\)/ig; # Look for a LIMIT clause ($limit) = ($nonstring =~ m(/\* LIMIT (\d*) \*/)o); @@ -529,6 +543,88 @@ sub bz_setup_database { . " RETURN DATE IS BEGIN RETURN SYSDATE; END;"); $self->do("CREATE OR REPLACE FUNCTION CHAR_LENGTH(COLUMN_NAME VARCHAR2)" . " RETURN NUMBER IS BEGIN RETURN LENGTH(COLUMN_NAME); END;"); + + # Create types for group_concat + my $t_clob_delim = $self->selectcol_arrayref(" + SELECT TYPE_NAME FROM USER_TYPES WHERE TYPE_NAME=?", + undef, 'T_CLOB_DELIM'); + + if ( !@$t_clob_delim ) { + $self->do("CREATE OR REPLACE TYPE T_CLOB_DELIM AS OBJECT " + . "( p_CONTENT CLOB, p_DELIMITER VARCHAR2(256));"); + } + + $self->do("CREATE OR REPLACE TYPE T_GROUP_CONCAT AS OBJECT + ( CLOB_CONTENT CLOB, + DELIMITER VARCHAR2(256), + STATIC FUNCTION ODCIAGGREGATEINITIALIZE( + SCTX IN OUT NOCOPY T_GROUP_CONCAT) + RETURN NUMBER, + MEMBER FUNCTION ODCIAGGREGATEITERATE( + SELF IN OUT NOCOPY T_GROUP_CONCAT, + VALUE IN T_CLOB_DELIM) + RETURN NUMBER, + MEMBER FUNCTION ODCIAGGREGATETERMINATE( + SELF IN T_GROUP_CONCAT, + RETURNVALUE OUT NOCOPY CLOB, + FLAGS IN NUMBER) + RETURN NUMBER, + MEMBER FUNCTION ODCIAGGREGATEMERGE( + SELF IN OUT NOCOPY T_GROUP_CONCAT, + CTX2 IN T_GROUP_CONCAT) + RETURN NUMBER);"); + + $self->do("CREATE OR REPLACE TYPE BODY T_GROUP_CONCAT IS + STATIC FUNCTION ODCIAGGREGATEINITIALIZE( + SCTX IN OUT NOCOPY T_GROUP_CONCAT) + RETURN NUMBER IS + BEGIN + SCTX := T_GROUP_CONCAT(EMPTY_CLOB(), NULL); + DBMS_LOB.CREATETEMPORARY(SCTX.CLOB_CONTENT, TRUE); + RETURN ODCICONST.SUCCESS; + END; + MEMBER FUNCTION ODCIAGGREGATEITERATE( + SELF IN OUT NOCOPY T_GROUP_CONCAT, + VALUE IN T_CLOB_DELIM) + RETURN NUMBER IS + BEGIN + SELF.DELIMITER := VALUE.P_DELIMITER; + DBMS_LOB.WRITEAPPEND(SELF.CLOB_CONTENT, + LENGTH(SELF.DELIMITER), + SELF.DELIMITER); + DBMS_LOB.APPEND(SELF.CLOB_CONTENT, VALUE.P_CONTENT); + + RETURN ODCICONST.SUCCESS; + END; + MEMBER FUNCTION ODCIAGGREGATETERMINATE( + SELF IN T_GROUP_CONCAT, + RETURNVALUE OUT NOCOPY CLOB, + FLAGS IN NUMBER) + RETURN NUMBER IS + BEGIN + RETURNVALUE := RTRIM(LTRIM(SELF.CLOB_CONTENT, + SELF.DELIMITER), + SELF.DELIMITER); + RETURN ODCICONST.SUCCESS; + END; + MEMBER FUNCTION ODCIAGGREGATEMERGE( + SELF IN OUT NOCOPY T_GROUP_CONCAT, + CTX2 IN T_GROUP_CONCAT) + RETURN NUMBER IS + BEGIN + DBMS_LOB.WRITEAPPEND(SELF.CLOB_CONTENT, + LENGTH(SELF.DELIMITER), + SELF.DELIMITER); + DBMS_LOB.APPEND(SELF.CLOB_CONTENT, CTX2.CLOB_CONTENT); + RETURN ODCICONST.SUCCESS; + END; + END;"); + + # Create user-defined aggregate function group_concat + $self->do("CREATE OR REPLACE FUNCTION GROUP_CONCAT(P_INPUT T_CLOB_DELIM) + RETURN CLOB + DETERMINISTIC PARALLEL_ENABLE AGGREGATE USING T_GROUP_CONCAT;"); + # Create a WORLD_LEXER named BZ_LEX for multilingual fulltext search my $lexer = $self->selectcol_arrayref( "SELECT pre_name FROM CTXSYS.CTX_PREFERENCES WHERE pre_name = ? AND @@ -580,6 +676,14 @@ sub bz_setup_database { } } + # Drop the trigger which causes bug 541553 + my $trigger_name = "PRODUCTS_MILESTONEURL"; + my $exist_trigger = $self->selectcol_arrayref( + "SELECT OBJECT_NAME FROM USER_OBJECTS + WHERE OBJECT_NAME = ?", undef, $trigger_name); + if(@$exist_trigger) { + $self->do("DROP TRIGGER $trigger_name"); + } } package Bugzilla::DB::Oracle::st; diff --git a/Bugzilla/DB/Pg.pm b/Bugzilla/DB/Pg.pm index ce9b3ec5e..b31d186bb 100644 --- a/Bugzilla/DB/Pg.pm +++ b/Bugzilla/DB/Pg.pm @@ -94,6 +94,12 @@ sub bz_last_key { return $last_insert_id; } +sub sql_group_concat { + my ($self, $text, $separator) = @_; + $separator ||= "','"; + return "array_to_string(array_accum($text), $separator)"; +} + sub sql_istring { my ($self, $string) = @_; @@ -201,6 +207,20 @@ sub bz_setup_database { my $self = shift; $self->SUPER::bz_setup_database(@_); + # Custom Functions + my $function = 'array_accum'; + my $array_accum = $self->selectrow_array( + 'SELECT 1 FROM pg_proc WHERE proname = ?', undef, $function); + if (!$array_accum) { + print "Creating function $function...\n"; + $self->do("CREATE AGGREGATE array_accum ( + SFUNC = array_append, + BASETYPE = anyelement, + STYPE = anyarray, + INITCOND = '{}' + )"); + } + # PostgreSQL doesn't like having *any* index on the thetext # field, because it can't have index data longer than 2770 # characters on that field. diff --git a/Bugzilla/DB/Schema.pm b/Bugzilla/DB/Schema.pm index 2c6ab6b89..0fa79d6f0 100644 --- a/Bugzilla/DB/Schema.pm +++ b/Bugzilla/DB/Schema.pm @@ -22,6 +22,7 @@ # Lance Larsh # Dennis Melentyev # Akamai Technologies +# Elliotte Martin package Bugzilla::DB::Schema; @@ -240,7 +241,9 @@ use constant ABSTRACT_SCHEMA => { FIELDS => [ bug_id => {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1}, - assigned_to => {TYPE => 'INT3', NOTNULL => 1}, + assigned_to => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid'}}, bug_file_loc => {TYPE => 'MEDIUMTEXT'}, bug_severity => {TYPE => 'varchar(64)', NOTNULL => 1}, bug_status => {TYPE => 'varchar(64)', NOTNULL => 1}, @@ -249,16 +252,24 @@ use constant ABSTRACT_SCHEMA => { short_desc => {TYPE => 'varchar(255)', NOTNULL => 1}, op_sys => {TYPE => 'varchar(64)', NOTNULL => 1}, priority => {TYPE => 'varchar(64)', NOTNULL => 1}, - product_id => {TYPE => 'INT2', NOTNULL => 1}, + product_id => {TYPE => 'INT2', NOTNULL => 1, + REFERENCES => {TABLE => 'products', + COLUMN => 'id'}}, rep_platform => {TYPE => 'varchar(64)', NOTNULL => 1}, - reporter => {TYPE => 'INT3', NOTNULL => 1}, + reporter => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid'}}, version => {TYPE => 'varchar(64)', NOTNULL => 1}, - component_id => {TYPE => 'INT2', NOTNULL => 1}, + component_id => {TYPE => 'INT2', NOTNULL => 1, + REFERENCES => {TABLE => 'components', + COLUMN => 'id'}}, resolution => {TYPE => 'varchar(64)', NOTNULL => 1, DEFAULT => "''"}, target_milestone => {TYPE => 'varchar(20)', NOTNULL => 1, DEFAULT => "'---'"}, - qa_contact => {TYPE => 'INT3'}, + qa_contact => {TYPE => 'INT3', + REERENCES => {TABLE => 'profiles', + COLUMN => 'userid'}}, status_whiteboard => {TYPE => 'MEDIUMTEXT', NOTNULL => 1, DEFAULT => "''"}, votes => {TYPE => 'INT3', NOTNULL => 1, @@ -273,9 +284,9 @@ use constant ABSTRACT_SCHEMA => { NOTNULL => 1, DEFAULT => 'TRUE'}, cclist_accessible => {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE'}, - estimated_time => {TYPE => 'decimal(5,2)', + estimated_time => {TYPE => 'decimal(7,2)', NOTNULL => 1, DEFAULT => '0'}, - remaining_time => {TYPE => 'decimal(5,2)', + remaining_time => {TYPE => 'decimal(7,2)', NOTNULL => 1, DEFAULT => '0'}, deadline => {TYPE => 'DATETIME'}, alias => {TYPE => 'varchar(20)'}, @@ -341,7 +352,7 @@ use constant ABSTRACT_SCHEMA => { fieldid => {TYPE => 'INT3', NOTNULL => 1, REFERENCES => {TABLE => 'fielddefs', COLUMN => 'id'}}, - added => {TYPE => 'TINYTEXT'}, + added => {TYPE => 'varchar(255)'}, removed => {TYPE => 'TINYTEXT'}, ], INDEXES => [ @@ -349,6 +360,7 @@ use constant ABSTRACT_SCHEMA => { bugs_activity_who_idx => ['who'], bugs_activity_bug_when_idx => ['bug_when'], bugs_activity_fieldid_idx => ['fieldid'], + bugs_activity_added_idx => ['added'], ], }, @@ -374,10 +386,15 @@ use constant ABSTRACT_SCHEMA => { FIELDS => [ comment_id => {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1}, - bug_id => {TYPE => 'INT3', NOTNULL => 1}, - who => {TYPE => 'INT3', NOTNULL => 1}, + bug_id => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'bugs', + COLUMN => 'bug_id', + DELETE => 'CASCADE'}}, + who => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid'}}, bug_when => {TYPE => 'DATETIME', NOTNULL => 1}, - work_time => {TYPE => 'decimal(5,2)', NOTNULL => 1, + work_time => {TYPE => 'decimal(7,2)', NOTNULL => 1, DEFAULT => '0'}, thetext => {TYPE => 'LONGTEXT', NOTNULL => 1}, isprivate => {TYPE => 'BOOLEAN', NOTNULL => 1, @@ -592,10 +609,12 @@ use constant ABSTRACT_SCHEMA => { DEFAULT => '0'}, grant_group_id => {TYPE => 'INT3', REFERENCES => {TABLE => 'groups', - COLUMN => 'id'}}, + COLUMN => 'id', + DELETE => 'SET NULL'}}, request_group_id => {TYPE => 'INT3', REFERENCES => {TABLE => 'groups', - COLUMN => 'id'}}, + COLUMN => 'id', + DELETE => 'SET NULL'}}, ], }, @@ -666,7 +685,7 @@ use constant ABSTRACT_SCHEMA => { DEFAULT => 'FALSE'}, buglist => {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE'}, - visibility_field_id => {TYPE => 'INT3', + visibility_field_id => {TYPE => 'INT3', REFERENCES => {TABLE => 'fielddefs', COLUMN => 'id'}}, # CustIS Bug 53617 - visibility_value_id is removed from here @@ -784,8 +803,14 @@ use constant ABSTRACT_SCHEMA => { status_workflow => { FIELDS => [ # On bug creation, there is no old value. - old_status => {TYPE => 'INT2'}, - new_status => {TYPE => 'INT2', NOTNULL => 1}, + old_status => {TYPE => 'INT2', + REFERENCES => {TABLE => 'bug_status', + COLUMN => 'id', + DELETE => 'CASCADE'}}, + new_status => {TYPE => 'INT2', NOTNULL => 1, + REFERENCES => {TABLE => 'bug_status', + COLUMN => 'id', + DELETE => 'CASCADE'}}, require_comment => {TYPE => 'INT1', NOTNULL => 1, DEFAULT => 0}, ], INDEXES => [ @@ -946,7 +971,7 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE'}}, - ipaddr => {TYPE => 'varchar(40)', NOTNULL => 1}, + ipaddr => {TYPE => 'varchar(40)'}, lastused => {TYPE => 'DATETIME', NOTNULL => 1}, ], INDEXES => [ @@ -954,6 +979,25 @@ use constant ABSTRACT_SCHEMA => { ], }, + login_failure => { + FIELDS => [ + user_id => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE'}}, + login_time => {TYPE => 'DATETIME', NOTNULL => 1}, + ip_addr => {TYPE => 'varchar(40)', NOTNULL => 1}, + ], + INDEXES => [ + # We do lookups by every item in the table simultaneously, but + # having an index with all three items would be the same size as + # the table. So instead we have an index on just the smallest item, + # to speed lookups. + login_failure_user_id_idx => ['user_id'], + ], + }, + + # "tokens" stores the tokens users receive when a password or email # change is requested. Tokens provide an extra measure of security # for these changes. @@ -1004,10 +1048,12 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'products', COLUMN => 'id', DELETE => 'CASCADE'}}, - entry => {TYPE => 'BOOLEAN', NOTNULL => 1}, + entry => {TYPE => 'BOOLEAN', NOTNULL => 1, + DEFAULT => 'FALSE'}, membercontrol => {TYPE => 'BOOLEAN', NOTNULL => 1}, othercontrol => {TYPE => 'BOOLEAN', NOTNULL => 1}, - canedit => {TYPE => 'BOOLEAN', NOTNULL => 1}, + canedit => {TYPE => 'BOOLEAN', NOTNULL => 1, + DEFAULT => 'FALSE'}, editcomponents => {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE'}, editbugs => {TYPE => 'BOOLEAN', NOTNULL => 1, @@ -1159,12 +1205,13 @@ use constant ABSTRACT_SCHEMA => { PRIMARYKEY => 1}, name => {TYPE => 'varchar(64)', NOTNULL => 1}, classification_id => {TYPE => 'INT2', NOTNULL => 1, - DEFAULT => '1'}, + DEFAULT => '1', + REFERENCES => {TABLE => 'classifications', + COLUMN => 'id', + DELETE => 'CASCADE'}}, description => {TYPE => 'MEDIUMTEXT'}, - milestoneurl => {TYPE => 'TINYTEXT', NOTNULL => 1, - DEFAULT => "''"}, - disallownew => {TYPE => 'BOOLEAN', NOTNULL => 1, - DEFAULT => 0}, + isactive => {TYPE => 'BOOLEAN', NOTNULL => 1, + DEFAULT => 1}, votesperuser => {TYPE => 'INT2', NOTNULL => 1, DEFAULT => 0}, maxvotesperbug => {TYPE => 'INT2', NOTNULL => 1, @@ -1173,6 +1220,8 @@ use constant ABSTRACT_SCHEMA => { DEFAULT => 0}, defaultmilestone => {TYPE => 'varchar(20)', NOTNULL => 1, DEFAULT => "'---'"}, + allows_unconfirmed => {TYPE => 'BOOLEAN', NOTNULL => 1, + DEFAULT => 'FALSE'}, ], INDEXES => [ products_name_idx => {FIELDS => ['name'], @@ -1215,7 +1264,7 @@ use constant ABSTRACT_SCHEMA => { creator => {TYPE => 'INT3', REFERENCES => {TABLE => 'profiles', COLUMN => 'userid', - DELETE => 'SET NULL'}}, + DELETE => 'CASCADE'}}, category => {TYPE => 'INT2', NOTNULL => 1, REFERENCES => {TABLE => 'series_categories', COLUMN => 'id', @@ -1226,7 +1275,6 @@ use constant ABSTRACT_SCHEMA => { DELETE => 'CASCADE'}}, name => {TYPE => 'varchar(64)', NOTNULL => 1}, frequency => {TYPE => 'INT2', NOTNULL => 1}, - last_viewed => {TYPE => 'DATETIME'}, query => {TYPE => 'MEDIUMTEXT', NOTNULL => 1}, is_public => {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE'}, @@ -1321,6 +1369,8 @@ use constant ABSTRACT_SCHEMA => { DELETE => 'CASCADE'}}, subject => {TYPE => 'varchar(128)'}, body => {TYPE => 'MEDIUMTEXT'}, + mailifnobugs => {TYPE => 'BOOLEAN', NOTNULL => 1, + DEFAULT => 'FALSE'}, ], }, @@ -1388,7 +1438,10 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE'}}, - setting_name => {TYPE => 'varchar(32)', NOTNULL => 1}, + setting_name => {TYPE => 'varchar(32)', NOTNULL => 1, + REFERENCES => {TABLE => 'setting', + COLUMN => 'name', + DELETE => 'CASCADE'}}, setting_value => {TYPE => 'varchar(32)', NOTNULL => 1}, ], INDEXES => [ @@ -1443,13 +1496,13 @@ use constant ABSTRACT_SCHEMA => { }, ts_note => { - FIELDS => [ + FIELDS => [ # This is a BIGINT in standard TheSchwartz schemas. jobid => {TYPE => 'INT4', NOTNULL => 1}, notekey => {TYPE => 'varchar(255)'}, value => {TYPE => 'LONGBLOB'}, - ], - INDEXES => [ + ], + INDEXES => [ ts_note_jobid_idx => {FIELDS => [qw(jobid notekey)], TYPE => 'UNIQUE'}, ], @@ -1477,7 +1530,7 @@ use constant ABSTRACT_SCHEMA => { status => {TYPE => 'INT2'}, completion_time => {TYPE => 'INT4'}, delete_after => {TYPE => 'INT4'}, - ], + ], INDEXES => [ ts_exitstatus_funcid_idx => ['funcid'], ts_exitstatus_delete_after_idx => ['delete_after'], @@ -1507,7 +1560,6 @@ use constant MULTI_SELECT_VALUE_TABLE => { ], }; - #-------------------------------------------------------------------------- =head1 METHODS @@ -1599,7 +1651,7 @@ sub _initialize { if exists $abstract_schema->{$table}; } unlock_keys(%$abstract_schema); - Bugzilla::Hook::process('db_schema-abstract_schema', + Bugzilla::Hook::process('db_schema_abstract_schema', { schema => $abstract_schema }); unlock_hash(%$abstract_schema); } diff --git a/Bugzilla/DB/Schema/Mysql.pm b/Bugzilla/DB/Schema/Mysql.pm index 627716970..a68c7c90d 100644 --- a/Bugzilla/DB/Schema/Mysql.pm +++ b/Bugzilla/DB/Schema/Mysql.pm @@ -178,13 +178,35 @@ sub get_alter_column_ddl { delete $new_def_copy{PRIMARYKEY}; } - my $new_ddl = $self->get_type_ddl(\%new_def_copy); my @statements; push(@statements, "UPDATE $table SET $column = $set_nulls_to WHERE $column IS NULL") if defined $set_nulls_to; - push(@statements, "ALTER TABLE $table CHANGE COLUMN + + # Calling SET DEFAULT or DROP DEFAULT is *way* faster than calling + # CHANGE COLUMN, so just do that if we're just changing the default. + my %old_defaultless = %$old_def; + my %new_defaultless = %$new_def; + delete $old_defaultless{DEFAULT}; + delete $new_defaultless{DEFAULT}; + if (!$self->columns_equal($old_def, $new_def) + && $self->columns_equal(\%new_defaultless, \%old_defaultless)) + { + if (!defined $new_def->{DEFAULT}) { + push(@statements, + "ALTER TABLE $table ALTER COLUMN $column DROP DEFAULT"); + } + else { + push(@statements, "ALTER TABLE $table ALTER COLUMN $column + SET DEFAULT " . $new_def->{DEFAULT}); + } + } + else { + my $new_ddl = $self->get_type_ddl(\%new_def_copy); + push(@statements, "ALTER TABLE $table CHANGE COLUMN $column $column $new_ddl"); + } + if ($old_def->{PRIMARYKEY} && !$new_def->{PRIMARYKEY}) { # Dropping a PRIMARY KEY needs an explicit DROP PRIMARY KEY push(@statements, "ALTER TABLE $table DROP PRIMARY KEY"); @@ -241,6 +263,11 @@ sub get_rename_indexes_ddl { return ($sql); } +sub get_set_serial_sql { + my ($self, $table, $column, $value) = @_; + return ("ALTER TABLE $table AUTO_INCREMENT = $value"); +} + # Converts a DBI column_info output to an abstract column definition. # Expects to only be called by Bugzila::DB::Mysql::_bz_build_schema_from_disk, # although there's a chance that it will also work properly if called diff --git a/Bugzilla/DB/Schema/Oracle.pm b/Bugzilla/DB/Schema/Oracle.pm index 8332be707..e8905eb80 100644 --- a/Bugzilla/DB/Schema/Oracle.pm +++ b/Bugzilla/DB/Schema/Oracle.pm @@ -145,6 +145,9 @@ sub get_fk_ddl { my $to_column = $references->{COLUMN} || confess "No column in reference"; my $fk_name = $self->_get_fk_name($table, $column, $references); + # 'ON DELETE RESTRICT' is enabled by default + $delete = "" if ( defined $delete && $delete =~ /RESTRICT/i); + my $fk_string = "\n CONSTRAINT $fk_name FOREIGN KEY ($column)\n" . " REFERENCES $to_table($to_column)\n"; @@ -400,4 +403,29 @@ sub _get_create_seq_ddl { return @ddl; } +sub get_set_serial_sql { + my ($self, $table, $column, $value) = @_; + my @sql; + my $seq_name = "${table}_${column}_SEQ"; + push(@sql, "DROP SEQUENCE ${seq_name}"); + push(@sql, $self->_get_create_seq_ddl($table, $column, $value)); + return @sql; +} + +sub get_drop_column_ddl { + my $self = shift; + my ($table, $column) = @_; + my @sql; + push(@sql, $self->SUPER::get_drop_column_ddl(@_)); + my $dbh=Bugzilla->dbh; + my $trigger_name = uc($table . "_" . $column); + my $exist_trigger = $dbh->selectcol_arrayref( + "SELECT OBJECT_NAME FROM USER_OBJECTS + WHERE OBJECT_NAME = ?", undef, $trigger_name); + if(@$exist_trigger) { + push(@sql, "DROP TRIGGER $trigger_name"); + } + return @sql; +} + 1; diff --git a/Bugzilla/DB/Schema/Pg.pm b/Bugzilla/DB/Schema/Pg.pm index 070c0b03e..3559bae9c 100644 --- a/Bugzilla/DB/Schema/Pg.pm +++ b/Bugzilla/DB/Schema/Pg.pm @@ -119,6 +119,12 @@ sub get_rename_table_sql { return ("ALTER TABLE $old_name RENAME TO $new_name"); } +sub get_set_serial_sql { + my ($self, $table, $column, $value) = @_; + return ("SELECT setval('${table}_${column}_seq', $value, false) + FROM $table"); +} + sub _get_alter_type_sql { my ($self, $table, $column, $new_def, $old_def) = @_; my @statements; diff --git a/Bugzilla/Error.pm b/Bugzilla/Error.pm index ff812b287..e3d8f767b 100644 --- a/Bugzilla/Error.pm +++ b/Bugzilla/Error.pm @@ -66,7 +66,7 @@ sub _error_message my $mesg = ''; $mesg .= "[$$] " . time2str("%D %H:%M:%S ", time()); $mesg .= uc($type)." $error "; - $mesg .= "$ENV{REMOTE_ADDR}" if $ENV{REMOTE_ADDR}; + $mesg .= remote_ip(); if (Bugzilla->user) { $mesg .= ' ' . Bugzilla->user->login; @@ -161,19 +161,37 @@ sub _throw_error print Bugzilla->cgi->header(); print $message; } - elsif ($mode == ERROR_MODE_DIE_SOAP_FAULT) + elsif ($mode == ERROR_MODE_DIE_SOAP_FAULT || Bugzilla->error_mode == ERROR_MODE_JSON_RPC) { # Clone the hash so we aren't modifying the constant. my %error_map = %{ WS_ERROR_CODE() }; require Bugzilla::Hook; - Bugzilla::Hook::process('webservice-error_codes', + Bugzilla::Hook::process('webservice_error_codes', { error_map => \%error_map }); my $code = $error_map{$error}; if (!$code) { $code = ERROR_UNKNOWN_FATAL if $type eq 'code'; $code = ERROR_UNKNOWN_TRANSIENT if $type eq 'user'; } - die bless { message => SOAP::Fault->faultcode($code)->faultstring($message) }; + if (Bugzilla->error_mode == ERROR_MODE_DIE_SOAP_FAULT) { + die bless { message => SOAP::Fault->faultcode($code)->faultstring($message) }; + } + else { + my $server = Bugzilla->_json_server; + # Technically JSON-RPC isn't allowed to have error numbers + # higher than 999, but we do this to avoid conflicts with + # the internal JSON::RPC error codes. + $server->raise_error(code => 100000 + $code, + message => $message, + id => $server->{_bz_request_id}, + version => $server->version); + # Most JSON-RPC Throw*Error calls happen within an eval inside + # of JSON::RPC. So, in that circumstance, instead of exiting, + # we die with no message. JSON::RPC checks raise_error before + # it checks $@, so it returns the proper error. + die if _in_eval(); + $server->response($server->error_response_header); + } } elsif ($mode == ERROR_MODE_AJAX) { diff --git a/Bugzilla/Extension.pm b/Bugzilla/Extension.pm new file mode 100644 index 000000000..fd8eca33d --- /dev/null +++ b/Bugzilla/Extension.pm @@ -0,0 +1,222 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developers are Copyright (C) 2009 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +package Bugzilla::Extension; + +use strict; + +# Don't use any more Bugzilla modules here as Bugzilla::Extension +# could be used outside of normal running Bugzilla installation +# (i.e. in checksetup.pl) + +use Bugzilla::Constants; +use Bugzilla::Util; +use Bugzilla::Hook; + +use Cwd qw(abs_path); +use File::Basename; +use File::Spec::Functions; + +use base 'Exporter'; +our @EXPORT = qw(extension_info required_modules optional_modules extension_version extension_include extension_template_dir extension_code_dir set_hook); + +my $extensions = { +# name => { +# required_modules => [], +# optional_modules => [], +# version => '', +# loaded => boolean, +# inc => [ 'path1', 'path2' ], +# } +}; + +# List all available extension names +sub available +{ + my $dir = bz_locations()->{extensionsdir}; + my @extension_items = glob(catfile($dir, '*')); + my @r; + foreach my $item (@extension_items) + { + my $basename = basename($item); + # Skip CVS directories and any hidden files/dirs. + next if $basename eq 'CVS' or $basename =~ /^\./; + if (-d $item) + { + if (!-e catfile($item, "disabled")) + { + trick_taint($basename); + push @r, $basename; + } + } + } + return @r; +} + +# List all loaded extensions +sub loaded +{ + return grep { $extensions->{$_}->{loaded} } keys %$extensions; +} + +# Get extensions information hashref +sub extension_info +{ + shift if $_[0] eq __PACKAGE__ || ref $_[0]; + my ($name) = @_; + return $extensions->{$name}; +} + +# Getters/setters for REQUIRED_MODULES, OPTIONAL_MODULES and version +sub required_modules { setter('required_modules', @_) } +sub optional_modules { setter('optional_modules', @_) } +sub extension_version { setter('version', @_) } + +# Getter/setter for extension code directory (for old extension system) +sub extension_code_dir +{ + my ($name, $new) = @_; + my $old = setter('code_dir', $name, $new); + return $old || catfile(bz_locations()->{extensionsdir}, $name, 'code'); +} + +# Getter/setter for extension template directory +sub extension_template_dir +{ + my ($name, $new) = @_; + my $old = setter('template_dir', $name, $new); + return $old || catfile(bz_locations()->{extensionsdir}, $name, 'template'); +} + +# Getter/setter for extension include path (@INC) +sub extension_include +{ + my ($name, $new) = @_; + if ($new) + { + if (ref $new && $new !~ /ARRAY/) + { + die __PACKAGE__."::extension_include('$name', '$new'): second argument should be an arrayref"; + } + $new = [ $new ] if !ref $new; + $new = [ map { abs_path($_) } @$new ]; + trick_taint($_) for @$new; + } + my $old = setter('inc', $name, $new); + # update @INC + my $oh = { map { $_ => 1 } @$old }; + for (my $i = $#INC; $i >= 0; $i--) + { + splice @INC, $i, 1 if $oh->{$INC[$i]}; + } + unshift @INC, @$new if $new; + return $old; +} + +# Generic getter/setter +sub setter +{ + my ($key, $name, $value) = @_; + $extensions->{$name} ||= {}; + my $old = $extensions->{$name}->{$key}; + $extensions->{$name}->{$key} = $value if defined $value; + return $old; +} + +# Load all available extensions +sub load_all +{ + shift if $_[0] && ($_[0] eq __PACKAGE__ || ref $_[0]); + foreach (available()) + { + load($_); + } +} + +# Load one extension +sub load +{ + my ($name) = @_; + if ($extensions->{$name} && $extensions->{$name}->{loaded}) + { + # Extension is already loaded + return; + } + + my $dir = bz_locations()->{extensionsdir}; + # Add default include path + extension_include($name, catfile($dir, $name, 'lib')); + + # Load main extension file + my $file = catfile($dir, $name, "$name.pl"); + if (-e $file) + { + trick_taint($file); + require $file; + } + + # Support for old extension system + my $code_dir = extension_code_dir($name); + if (-d $code_dir) + { + my @hooks = glob(catfile($code_dir, '*.pl')); + my ($hook, $hook_sub); + foreach my $filename (@hooks) + { + trick_taint($filename); + $hook = basename($filename); + $hook =~ s/\.pl$//so; + if (!-r $filename) + { + warn __PACKAGE__."::load(): can't read $filename, skipping"; + next; + } + set_hook($name, $hook, { type => 'file', filename => $filename }); + } + } + + $extensions->{$name}->{loaded} = 1; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Extension - Base class for Bugzilla Extensions. + +=head1 BUGZILLA::EXTENSION CLASS METHODS + +These are used internally by Bugzilla to load and set up extensions. +If you are an extension author, you don't need to care about these. + +=head2 C + +Takes two arguments, the path to F and the path to F, +for an extension. Loads the extension's code packages into memory using +C, does some sanity-checking on the extension, and returns the +package name of the loaded extension. + +=head2 C + +Calls L for every enabled extension installed into Bugzilla, +and returns an arrayref of all the package names that were loaded. diff --git a/Bugzilla/Field.pm b/Bugzilla/Field.pm index 27c210c8f..4b3e51c4e 100644 --- a/Bugzilla/Field.pm +++ b/Bugzilla/Field.pm @@ -15,6 +15,7 @@ # Contributor(s): Dan Mosedale # Frédéric Buclin # Myk Melez +# Greg Hendricks =head1 NAME @@ -106,14 +107,14 @@ use constant DB_COLUMNS => qw( use constant REQUIRED_CREATE_FIELDS => qw(name description); use constant VALIDATORS => { - custom => \&_check_custom, - description => \&_check_description, - enter_bug => \&_check_enter_bug, - buglist => \&Bugzilla::Object::check_boolean, - mailhead => \&_check_mailhead, - obsolete => \&_check_obsolete, - sortkey => \&_check_sortkey, - type => \&_check_type, + custom => \&_check_custom, + description => \&_check_description, + enter_bug => \&_check_enter_bug, + buglist => \&Bugzilla::Object::check_boolean, + mailhead => \&_check_mailhead, + obsolete => \&_check_obsolete, + sortkey => \&_check_sortkey, + type => \&_check_type, visibility_field_id => \&_check_visibility_field_id, }; @@ -217,7 +218,7 @@ use constant DEFAULT_FIELDS => ( {name => 'deadline', desc => 'Deadline', in_new_bugmail => 1, buglist => 1}, {name => 'commenter', desc => 'Commenter'}, - {name => 'flagtypes.name', desc => 'Flag'}, + {name => 'flagtypes.name', desc => 'Flags', buglist => 1}, {name => 'requestees.login_name', desc => 'Flag Requestee'}, {name => 'setters.login_name', desc => 'Flag Setter'}, {name => 'work_time', desc => 'Hours Worked', buglist => 1}, @@ -469,9 +470,9 @@ objects. =cut -sub is_select { - return ($_[0]->type == FIELD_TYPE_SINGLE_SELECT - || $_[0]->type == FIELD_TYPE_MULTI_SELECT) ? 1 : 0 +sub is_select { + return ($_[0]->type == FIELD_TYPE_SINGLE_SELECT + || $_[0]->type == FIELD_TYPE_MULTI_SELECT) ? 1 : 0 } sub legal_values { @@ -511,7 +512,7 @@ Returns undef if there is no field that controls this field's visibility. sub visibility_field { my $self = shift; if ($self->{visibility_field_id}) { - $self->{visibility_field} ||= + $self->{visibility_field} ||= $self->new($self->{visibility_field_id}); } return $self->{visibility_field}; @@ -572,7 +573,7 @@ field controls the visibility of. sub controls_visibility_of { my $self = shift; - $self->{controls_visibility_of} ||= + $self->{controls_visibility_of} ||= Bugzilla::Field->match({ visibility_field_id => $self->id }); return $self->{controls_visibility_of}; } @@ -731,16 +732,14 @@ sub remove_from_db { $bugs_query = "SELECT COUNT(*) FROM bug_$name"; } else { - $bugs_query = "SELECT COUNT(*) FROM bugs WHERE $name IS NOT NULL - AND $name != ''"; + $bugs_query = "SELECT COUNT(*) FROM bugs WHERE $name IS NOT NULL"; + if ($self->type != FIELD_TYPE_BUG_ID && $self->type != FIELD_TYPE_DATETIME) { + $bugs_query .= " AND $name != ''"; + } # Ignore the default single select value if ($self->type == FIELD_TYPE_SINGLE_SELECT) { $bugs_query .= " AND $name != '---'"; } - # Ignore blank dates. - if ($self->type == FIELD_TYPE_DATETIME) { - $bugs_query .= " AND $name != '00-00-00 00:00:00'"; - } } my $has_bugs = $dbh->selectrow_array($bugs_query); @@ -845,6 +844,11 @@ sub run_create_validators { } my $type = $params->{type} || 0; + + if ($params->{custom} && !$type) { + ThrowCodeError('field_type_not_specified'); + } + $params->{value_field_id} = $class->_check_value_field_id($params->{value_field_id}, ($type == FIELD_TYPE_SINGLE_SELECT @@ -1032,8 +1036,14 @@ sub check_field { my $dbh = Bugzilla->dbh; # If $legalsRef is undefined, we use the default valid values. + # Valid values for this check are all possible values. + # Using get_legal_values would only return active values, but since + # some bugs may have inactive values set, we want to check them too. unless (defined $legalsRef) { - $legalsRef = get_legal_field_values($name); + $legalsRef = Bugzilla::Field->new({name => $name})->legal_values; + my @values = map($_->name, @$legalsRef); + $legalsRef = \@values; + } if (!defined($value) diff --git a/Bugzilla/Field/Choice.pm b/Bugzilla/Field/Choice.pm index 599d42f5f..89c0de874 100644 --- a/Bugzilla/Field/Choice.pm +++ b/Bugzilla/Field/Choice.pm @@ -17,6 +17,7 @@ # The Original Code is the Bugzilla Bug Tracking System. # # Contributor(s): Max Kanat-Alexander +# Greg Hendricks # Vitaliy Filippov use strict; @@ -45,11 +46,13 @@ use constant DB_COLUMNS => qw( id value sortkey + isactive ); use constant UPDATE_COLUMNS => qw( value sortkey + isactive ); use constant NAME_FIELD => 'value'; @@ -60,6 +63,7 @@ use constant REQUIRED_CREATE_FIELDS => qw(value); use constant VALIDATORS => { value => \&_check_value, sortkey => \&_check_sortkey, + isactive => \&Bugzilla::Object::check_boolean, }; use constant CLASS_MAP => { @@ -214,7 +218,8 @@ sub _check_if_controller { # Accessors # ############# -sub sortkey { return $_[0]->{'sortkey'}; } +sub is_active { return $_[0]->{'isactive'}; } +sub sortkey { return $_[0]->{'sortkey'}; } sub bug_count { my $self = shift; @@ -303,7 +308,7 @@ sub controlled_values { my $type = Bugzilla::Field::Choice->type($field); $f = $type->match({ id => $f }); - } + } $controlled_values->{$field->name} = $f; } $self->{controlled_values} = $controlled_values; @@ -317,7 +322,7 @@ sub controlled_plus_generic my $controlled_values; unless ($controlled_values = $self->{controlled_plus_generic}) { - my $fields = $self->field->controls_values_of; + my $fields = $self->field->controls_values_of; foreach my $field (@$fields) { my $f = Bugzilla->dbh->selectcol_arrayref( @@ -368,8 +373,9 @@ sub has_visibility_value # Mutators # ############ -sub set_name { $_[0]->set('value', $_[1]); } -sub set_sortkey { $_[0]->set('sortkey', $_[1]); } +sub set_is_active { $_[0]->set('isactive', $_[1]); } +sub set_name { $_[0]->set('value', $_[1]); } +sub set_sortkey { $_[0]->set('sortkey', $_[1]); } sub set_visibility_values { diff --git a/Bugzilla/Flag.pm b/Bugzilla/Flag.pm index ab1599805..8e213b0df 100644 --- a/Bugzilla/Flag.pm +++ b/Bugzilla/Flag.pm @@ -53,6 +53,9 @@ whose names start with _ or a re specifically noted as being private. =cut +use Scalar::Util qw(blessed); +use Storable qw(dclone); + use Bugzilla::FlagType; use Bugzilla::Hook; use Bugzilla::User; @@ -69,22 +72,45 @@ use base qw(Bugzilla::Object Exporter); #### Initialization #### ############################### -use constant DB_COLUMNS => qw( - flags.id - flags.type_id - flags.bug_id - flags.attach_id - flags.requestee_id - flags.setter_id - flags.status - flags.creation_date -); - use constant DB_TABLE => 'flags'; use constant LIST_ORDER => 'id'; use constant SKIP_REQUESTEE_ON_ERROR => 1; +use constant DB_COLUMNS => qw( + id + type_id + bug_id + attach_id + requestee_id + setter_id + status + creation_date +); + +use constant REQUIRED_CREATE_FIELDS => qw( + attach_id + bug_id + setter_id + status + type_id +); + +use constant UPDATE_COLUMNS => qw( + requestee_id + setter_id + status + type_id +); + +use constant VALIDATORS => { +}; + +use constant UPDATE_VALIDATORS => { + setter => \&_check_setter, + status => \&_check_status, +}; + ############################### #### Accessors ###### ############################### @@ -117,11 +143,14 @@ Returns the status '+', '-', '?' of the flag. =cut -sub id { return $_[0]->{'id'}; } -sub name { return $_[0]->type->name; } -sub bug_id { return $_[0]->{'bug_id'}; } -sub attach_id { return $_[0]->{'attach_id'}; } -sub status { return $_[0]->{'status'}; } +sub id { return $_[0]->{'id'}; } +sub name { return $_[0]->type->name; } +sub type_id { return $_[0]->{'type_id'}; } +sub bug_id { return $_[0]->{'bug_id'}; } +sub attach_id { return $_[0]->{'attach_id'}; } +sub status { return $_[0]->{'status'}; } +sub setter_id { return $_[0]->{'setter_id'}; } +sub requestee_id { return $_[0]->{'requestee_id'}; } ############################### #### Methods #### @@ -185,6 +214,14 @@ sub attachment { return $self->{'attachment'}; } +sub bug { + my $self = shift; + + require Bugzilla::Bug; + $self->{'bug'} ||= new Bugzilla::Bug($self->bug_id); + return $self->{'bug'}; +} + ################################ ## Searching/Retrieving Flags ## ################################ @@ -269,260 +306,171 @@ sub count { # Creating and Modifying ###################################################################### +sub set_flag { + my ($class, $obj, $params) = @_; + + my ($bug, $attachment); + if (blessed($obj) && $obj->isa('Bugzilla::Attachment')) { + $attachment = $obj; + $bug = $attachment->bug; + } + elsif (blessed($obj) && $obj->isa('Bugzilla::Bug')) { + $bug = $obj; + } + else { + ThrowCodeError('flag_unexpected_object', { 'caller' => ref $obj }); + } + + # Update (or delete) an existing flag. + if ($params->{id}) { + my $flag = $class->check({ id => $params->{id} }); + + # Security check: make sure the flag belongs to the bug/attachment. + # We don't check that the user editing the flag can see + # the bug/attachment. That's the job of the caller. + ($attachment && $flag->attach_id && $attachment->id == $flag->attach_id) + || (!$attachment && !$flag->attach_id && $bug->id == $flag->bug_id) + || ThrowCodeError('invalid_flag_association', + { bug_id => $bug->id, + attach_id => $attachment ? $attachment->id : undef }); + + # Extract the current flag object from the object. + my ($obj_flagtype) = grep { $_->id == $flag->type_id } @{$obj->flag_types}; + # If no flagtype can be found for this flag, this means the bug is being + # moved into a product/component where the flag is no longer valid. + # So either we can attach the flag to another flagtype having the same + # name, or we remove the flag. + if (!$obj_flagtype) { + my $success = $flag->retarget($obj); + return unless $success; + + ($obj_flagtype) = grep { $_->id == $flag->type_id } @{$obj->flag_types}; + push(@{$obj_flagtype->{flags}}, $flag); + } + my ($obj_flag) = grep { $_->id == $flag->id } @{$obj_flagtype->{flags}}; + # If the flag has the correct type but cannot be found above, this means + # the flag is going to be removed (e.g. because this is a pending request + # and the attachment is being marked as obsolete). + return unless $obj_flag; + + $class->_validate($obj_flag, $obj_flagtype, $params, $bug, $attachment); + } + # Create a new flag. + elsif ($params->{type_id}) { + # Don't bother validating types the user didn't touch. + return if $params->{status} eq 'X'; + + my $flagtype = Bugzilla::FlagType->check({ id => $params->{type_id} }); + # Security check: make sure the flag type belongs to the bug/attachment. + ($attachment && $flagtype->target_type eq 'attachment' + && scalar(grep { $_->id == $flagtype->id } @{$attachment->flag_types})) + || (!$attachment && $flagtype->target_type eq 'bug' + && scalar(grep { $_->id == $flagtype->id } @{$bug->flag_types})) + || ThrowCodeError('invalid_flag_association', + { bug_id => $bug->id, + attach_id => $attachment ? $attachment->id : undef }); + + # Make sure the flag type is active. + $flagtype->is_active + || ThrowCodeError('flag_type_inactive', { type => $flagtype->name }); + + # Extract the current flagtype object from the object. + my ($obj_flagtype) = grep { $_->id == $flagtype->id } @{$obj->flag_types}; + + # We cannot create a new flag if there is already one and this + # flag type is not multiplicable. + if (!$flagtype->is_multiplicable) { + if (scalar @{$obj_flagtype->{flags}}) { + ThrowUserError('flag_type_not_multiplicable', { type => $flagtype }); + } + } + + $class->_validate(undef, $obj_flagtype, $params, $bug, $attachment); + } + else { + ThrowCodeError('param_required', { function => $class . '->set_flag', + param => 'id/type_id' }); + } +} + +sub _validate { + my ($class, $flag, $flag_type, $params, $bug, $attachment) = @_; + + # If it's a new flag, let's create it now. + my $obj_flag = $flag || bless({ type_id => $flag_type->id, + status => '', + bug_id => $bug->id, + attach_id => $attachment ? + $attachment->id : undef}, + $class); + + my $old_status = $obj_flag->status; + my $old_requestee_id = $obj_flag->requestee_id; + + $obj_flag->_set_status($params->{status}); + $obj_flag->_set_requestee($params->{requestee}, $attachment, $params->{skip_roe}); + + # The setter field MUST NOT be updated if neither the status + # nor the requestee fields changed. + if (($obj_flag->status ne $old_status) + # The requestee ID can be undefined. + || (($obj_flag->requestee_id || 0) != ($old_requestee_id || 0))) + { + $obj_flag->_set_setter($params->{setter}); + } + + # If the flag is deleted, remove it from the list. + if ($obj_flag->status eq 'X') { + @{$flag_type->{flags}} = grep { $_->id != $obj_flag->id } @{$flag_type->{flags}}; + } + # Add the newly created flag to the list. + elsif (!$obj_flag->id) { + push(@{$flag_type->{flags}}, $obj_flag); + } +} + =pod =over -=item C +=item C -Validates fields containing flag modifications. - -If the attachment is new, it has no ID yet and $attach_id is set -to -1 to force its check anyway. +Creates a flag record in the database. =back =cut -sub validate { - my ($bug_id, $attach_id, $skip_requestee_on_error) = @_; - my $cgi = Bugzilla->cgi; - my $dbh = Bugzilla->dbh; +sub create { + my ($class, $flag, $timestamp) = @_; + $timestamp ||= Bugzilla->dbh->selectrow_array('SELECT NOW()'); - # Get a list of flags to validate. Uses the "map" function - # to extract flag IDs from form field names by matching fields - # whose name looks like "flag_type-nnn" (new flags) or "flag-nnn" - # (existing flags), where "nnn" is the ID, and returning just - # the ID portion of matching field names. - my @flagtype_ids = map(/^flag_type-(\d+)$/ ? $1 : (), $cgi->param()); - my @flag_ids = map(/^flag-(\d+)$/ ? $1 : (), $cgi->param()); + my $params = {}; + my @columns = grep { $_ ne 'id' } $class->DB_COLUMNS; + $params->{$_} = $flag->{$_} foreach @columns; - return unless (scalar(@flagtype_ids) || scalar(@flag_ids)); - - # No flag reference should exist when changing several bugs at once. - ThrowCodeError("flags_not_available", { type => 'b' }) unless $bug_id; - - # We don't check that these new flags are valid for this bug/attachment, - # because the bug may be moved into another product meanwhile. - # This check will be done later when creating new flags, see FormToNewFlags(). - - if (scalar(@flag_ids)) { - # No reference to existing flags should exist when creating a new - # attachment. - if ($attach_id && ($attach_id < 0)) { - ThrowCodeError('flags_not_available', { type => 'a' }); - } - - # Make sure all existing flags belong to the bug/attachment - # they pretend to be. - my $field = ($attach_id) ? "attach_id" : "bug_id"; - my $field_id = $attach_id || $bug_id; - my $not = ($attach_id) ? "" : "NOT"; - - my $invalid_data = - $dbh->selectrow_array( - "SELECT 1 FROM flags - WHERE " - . $dbh->sql_in('id', \@flag_ids) - . " AND ($field != ? OR attach_id IS $not NULL) " - . $dbh->sql_limit(1), undef, $field_id); - - if ($invalid_data) { - ThrowCodeError('invalid_flag_association', - { bug_id => $bug_id, - attach_id => $attach_id }); - } - } - - # Validate new flags. - foreach my $id (@flagtype_ids) { - my $status = $cgi->param("flag_type-$id"); - my @requestees = $cgi->param("requestee_type-$id"); - my $private_attachment = $cgi->param('isprivate') ? 1 : 0; - - # Don't bother validating types the user didn't touch. - next if $status eq 'X'; - - # Make sure the flag type exists. If it doesn't, FormToNewFlags() - # will ignore it, so it's safe to ignore it here. - my $flag_type = new Bugzilla::FlagType($id); - next unless $flag_type; - - # Make sure the flag type is active. - unless ($flag_type->is_active) { - ThrowCodeError('flag_type_inactive', {'type' => $flag_type->name}); - } - - _validate(undef, $flag_type, $status, undef, \@requestees, $private_attachment, - $bug_id, $attach_id, $skip_requestee_on_error); - } - - # Validate existing flags. - foreach my $id (@flag_ids) { - my $status = $cgi->param("flag-$id"); - my @requestees = $cgi->param("requestee-$id"); - my $private_attachment = $cgi->param('isprivate') ? 1 : 0; - - # Make sure the flag exists. If it doesn't, process() will ignore it, - # so it's safe to ignore it here. - my $flag = new Bugzilla::Flag($id); - next unless $flag; - - _validate($flag, $flag->type, $status, undef, \@requestees, $private_attachment, - undef, undef, $skip_requestee_on_error); - } + $params->{creation_date} = $params->{modification_date} = $timestamp; + $flag = $class->SUPER::create($params); + return $flag; } -sub _validate { - my ($flag, $flag_type, $status, $setter, $requestees, $private_attachment, - $bug_id, $attach_id, $skip_requestee_on_error) = @_; +sub update { + my $self = shift; + my $dbh = Bugzilla->dbh; + my $timestamp = shift || $dbh->selectrow_array('SELECT NOW()'); - # By default, the flag setter (or requester) is the current user. - $setter ||= Bugzilla->user; + my $changes = $self->SUPER::update(@_); - my $id = $flag ? $flag->id : $flag_type->id; # Used in the error messages below. - $bug_id ||= $flag->bug_id; - $attach_id ||= $flag->attach_id if $flag; # Maybe it's a bug flag. - - # Make sure the user chose a valid status. - grep($status eq $_, qw(X + - ?)) - || ThrowCodeError('flag_status_invalid', - { id => $id, status => $status }); - - # Make sure the user didn't request the flag unless it's requestable. - # If the flag existed and was requested before it became unrequestable, - # leave it as is. - if ($status eq '?' - && (!$flag || $flag->status ne '?') - && !$flag_type->is_requestable) - { - ThrowCodeError('flag_status_invalid', - { id => $id, status => $status }); + if (scalar(keys %$changes)) { + $dbh->do('UPDATE flags SET modification_date = ? WHERE id = ?', + undef, ($timestamp, $self->id)); } - - # Make sure the user didn't specify a requestee unless the flag - # is specifically requestable. For existing flags, if the requestee - # was set before the flag became specifically unrequestable, don't - # let the user change the requestee, but let the user remove it by - # entering an empty string for the requestee. - if ($status eq '?' && !$flag_type->is_requesteeble) { - my $old_requestee = ($flag && $flag->requestee) ? - $flag->requestee->login : ''; - my $new_requestee = join('', @$requestees); - if ($new_requestee && $new_requestee ne $old_requestee) { - ThrowCodeError('flag_requestee_disabled', - { type => $flag_type }); - } - } - - # Make sure the user didn't enter multiple requestees for a flag - # that can't be requested from more than one person at a time. - if ($status eq '?' - && !$flag_type->is_multiplicable - && scalar(@$requestees) > 1) - { - ThrowUserError('flag_not_multiplicable', { type => $flag_type }); - } - - # Make sure the requestees are authorized to access the bug - # (and attachment, if this installation is using the "insider group" - # feature and the attachment is marked private). - if ($status eq '?' && $flag_type->is_requesteeble) { - my $old_requestee = ($flag && $flag->requestee) ? - $flag->requestee->login : ''; - - my @legal_requestees; - foreach my $login (@$requestees) { - if ($login eq $old_requestee) { - # This requestee was already set. Leave him alone. - push(@legal_requestees, $login); - next; - } - - # We know the requestee exists because we ran - # Bugzilla::User::match_field before getting here. - my $requestee = new Bugzilla::User({ name => $login }); - - # Throw an error if the user can't see the bug. - # Note that if permissions on this bug are changed, - # can_see_bug() will refer to old settings. - if (!$requestee->can_see_bug($bug_id)) - { - if (Bugzilla->params->{auto_add_flag_requestees_to_cc}) - { - # Bug 55712 - Add flag requestees to CC list - Bugzilla->cgi->param(-name => 'newcc', -value => [ Bugzilla->cgi->param('newcc'), $requestee->login ]); - } - else - { - next if $skip_requestee_on_error; - ThrowUserError('flag_requestee_unauthorized', - { flag_type => $flag_type, - requestee => $requestee, - bug_id => $bug_id, - attach_id => $attach_id }); - } - } - - # Throw an error if the target is a private attachment and - # the requestee isn't in the group of insiders who can see it. - if ($attach_id - && $private_attachment - && Bugzilla->params->{'insidergroup'} - && !$requestee->in_group(Bugzilla->params->{'insidergroup'})) - { - next if $skip_requestee_on_error; - ThrowUserError('flag_requestee_unauthorized_attachment', - { flag_type => $flag_type, - requestee => $requestee, - bug_id => $bug_id, - attach_id => $attach_id }); - } - - # Throw an error if the user won't be allowed to set the flag. - if (!$requestee->can_set_flag($flag_type)) { - next if $skip_requestee_on_error; - ThrowUserError('flag_requestee_needs_privs', - {'requestee' => $requestee, - 'flagtype' => $flag_type}); - } - - # This requestee can be set. - push(@legal_requestees, $login); - } - - # Update the requestee list for this flag. - if (scalar(@legal_requestees) < scalar(@$requestees)) { - my $field_name = 'requestee_type-' . $flag_type->id; - Bugzilla->cgi->delete($field_name); - Bugzilla->cgi->param(-name => $field_name, -value => \@legal_requestees); - } - } - - # Make sure the user is authorized to modify flags, see bug 180879 - # - The flag exists and is unchanged. - return if ($flag && ($status eq $flag->status)); - - # - User in the request_group can clear pending requests and set flags - # and can rerequest set flags. - return if (($status eq 'X' || $status eq '?') - && $setter->can_request_flag($flag_type)); - - # - User in the grant_group can set/clear flags, including "+" and "-". - return if $setter->can_set_flag($flag_type); - - # - Any other flag modification is denied - ThrowUserError('flag_update_denied', - { name => $flag_type->name, - status => $status, - old_status => $flag ? $flag->status : 'X' }); + return $changes; } sub snapshot { - my ($class, $bug_id, $attach_id) = @_; + my ($class, $flags) = @_; - my $flags = $class->match({ 'bug_id' => $bug_id, - 'attach_id' => $attach_id }); my @summaries; foreach my $flag (@$flags) { my $summary = $flag->setter->nick . ':' . $flag->type->name . $flag->status; @@ -532,108 +480,8 @@ sub snapshot { return @summaries; } - -=pod - -=over - -=item C - -Processes changes to flags. - -The bug and/or the attachment objects are the ones this flag is about, -the timestamp is the date/time the bug was last touched (so that changes -to the flag can be stamped with the same date/time). - -=back - -=cut - -sub process { - my ($class, $bug, $attachment, $timestamp, $hr_vars) = @_; - my $dbh = Bugzilla->dbh; - my $cgi = Bugzilla->cgi; - - # Make sure the bug (and attachment, if given) exists and is accessible - # to the current user. Moreover, if an attachment object is passed, - # make sure it belongs to the given bug. - return if ($bug->error || ($attachment && $bug->bug_id != $attachment->bug_id)); - - my $bug_id = $bug->bug_id; - my $attach_id = $attachment ? $attachment->id : undef; - - # Use the date/time we were given if possible (allowing calling code - # to synchronize the comment's timestamp with those of other records). - $timestamp ||= $dbh->selectrow_array('SELECT NOW()'); - - # Take a snapshot of flags before any changes. - my @old_summaries = $class->snapshot($bug_id, $attach_id); - - # Cancel pending requests if we are obsoleting an attachment. - if ($attachment && $cgi->param('isobsolete')) { - $class->CancelRequests($bug, $attachment); - } - - # Create new flags and update existing flags. - my $new_flags = FormToNewFlags($bug, $attachment, $cgi, $hr_vars); - foreach my $flag (@$new_flags) { create($flag, $bug, $attachment, $timestamp) } - modify($bug, $attachment, $cgi, $timestamp); - - # In case the bug's product/component has changed, clear flags that are - # no longer valid. - my $flag_ids = $dbh->selectcol_arrayref( - "SELECT DISTINCT flags.id - FROM flags - INNER JOIN bugs - ON flags.bug_id = bugs.bug_id - LEFT JOIN flaginclusions AS i - ON flags.type_id = i.type_id - AND (bugs.product_id = i.product_id OR i.product_id IS NULL) - AND (bugs.component_id = i.component_id OR i.component_id IS NULL) - WHERE bugs.bug_id = ? - AND i.type_id IS NULL", - undef, $bug_id); - - my $flags = Bugzilla::Flag->new_from_list($flag_ids); - foreach my $flag (@$flags) { - my $is_retargetted = retarget($flag, $bug); - unless ($is_retargetted) { - clear($flag, $bug, $flag->attachment); - $hr_vars->{'message'} = 'flag_cleared'; - } - } - - $flag_ids = $dbh->selectcol_arrayref( - "SELECT DISTINCT flags.id - FROM flags, bugs, flagexclusions e - WHERE bugs.bug_id = ? - AND flags.bug_id = bugs.bug_id - AND flags.type_id = e.type_id - AND (bugs.product_id = e.product_id OR e.product_id IS NULL) - AND (bugs.component_id = e.component_id OR e.component_id IS NULL)", - undef, $bug_id); - - $flags = Bugzilla::Flag->new_from_list($flag_ids); - foreach my $flag (@$flags) { - my $is_retargetted = retarget($flag, $bug); - clear($flag, $bug, $flag->attachment) unless $is_retargetted; - } - - # Take a snapshot of flags after changes. - my @new_summaries = $class->snapshot($bug_id, $attach_id); - - update_activity($bug_id, $attach_id, $timestamp, \@old_summaries, \@new_summaries); - - Bugzilla::Hook::process('flag-end_of_update', { bug => $bug, - timestamp => $timestamp, - old_flags => \@old_summaries, - new_flags => \@new_summaries, - }); -} - sub update_activity { - my ($bug_id, $attach_id, $timestamp, $old_summaries, $new_summaries) = @_; - my $dbh = Bugzilla->dbh; + my ($class, $old_summaries, $new_summaries) = @_; my ($removed, $added) = diff_arrays($old_summaries, $new_summaries); if (scalar @$removed || scalar @$added) { @@ -644,99 +492,351 @@ sub update_activity { $added = join(", ", @$added); trick_taint($removed); trick_taint($added); - my $field_id = get_field_id('flagtypes.name'); - $dbh->do('INSERT INTO bugs_activity - (bug_id, attach_id, who, bug_when, fieldid, removed, added) - VALUES (?, ?, ?, ?, ?, ?, ?)', - undef, ($bug_id, $attach_id, Bugzilla->user->id, - $timestamp, $field_id, $removed, $added)); - - $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?', - undef, ($timestamp, $bug_id)); + return ($removed, $added); } + return (); } +sub update_flags { + my ($class, $self, $old_self, $timestamp) = @_; + + my @old_summaries = $class->snapshot($old_self->flags); + my %old_flags = map { $_->id => $_ } @{$old_self->flags}; + + foreach my $new_flag (@{$self->flags}) { + if (!$new_flag->id) { + # This is a new flag. + my $flag = $class->create($new_flag, $timestamp); + $new_flag->{id} = $flag->id; + $class->notify($new_flag, undef, $self); + } + else { + my $changes = $new_flag->update($timestamp); + if (scalar(keys %$changes)) { + $class->notify($new_flag, $old_flags{$new_flag->id}, $self); + } + delete $old_flags{$new_flag->id}; + } + } + # These flags have been deleted. + foreach my $old_flag (values %old_flags) { + $class->notify(undef, $old_flag, $self); + $old_flag->remove_from_db(); + } + + # If the bug has been moved into another product or component, + # we must also take care of attachment flags which are no longer valid, + # as well as all bug flags which haven't been forgotten above. + if ($self->isa('Bugzilla::Bug') + && ($self->{_old_product_name} || $self->{_old_component_name})) + { + my @removed = $class->force_cleanup($self); + push(@old_summaries, @removed); + } + + my @new_summaries = $class->snapshot($self->flags); + my @changes = $class->update_activity(\@old_summaries, \@new_summaries); + + Bugzilla::Hook::process('flag_end_of_update', { object => $self, + timestamp => $timestamp, + old_flags => \@old_summaries, + new_flags => \@new_summaries, + }); + return @changes; +} + +sub retarget { + my ($self, $obj) = @_; + + my @flagtypes = grep { $_->name eq $self->type->name } @{$obj->flag_types}; + + my $success = 0; + foreach my $flagtype (@flagtypes) { + next if !$flagtype->is_active; + next if (!$flagtype->is_multiplicable && scalar @{$flagtype->{flags}}); + next unless (($self->status eq '?' && $self->setter->can_request_flag($flagtype)) + || $self->setter->can_set_flag($flagtype)); + + $self->{type_id} = $flagtype->id; + delete $self->{type}; + $success = 1; + last; + } + return $success; +} + +# In case the bug's product/component has changed, clear flags that are +# no longer valid. +sub force_cleanup { + my ($class, $bug) = @_; + my $dbh = Bugzilla->dbh; + + my $flag_ids = $dbh->selectcol_arrayref( + 'SELECT DISTINCT flags.id + FROM flags + INNER JOIN bugs + ON flags.bug_id = bugs.bug_id + LEFT JOIN flaginclusions AS i + ON flags.type_id = i.type_id + AND (bugs.product_id = i.product_id OR i.product_id IS NULL) + AND (bugs.component_id = i.component_id OR i.component_id IS NULL) + WHERE bugs.bug_id = ? AND i.type_id IS NULL', + undef, $bug->id); + + my @removed = $class->force_retarget($flag_ids, $bug); + + $flag_ids = $dbh->selectcol_arrayref( + 'SELECT DISTINCT flags.id + FROM flags, bugs, flagexclusions e + WHERE bugs.bug_id = ? + AND flags.bug_id = bugs.bug_id + AND flags.type_id = e.type_id + AND (bugs.product_id = e.product_id OR e.product_id IS NULL) + AND (bugs.component_id = e.component_id OR e.component_id IS NULL)', + undef, $bug->id); + + push(@removed , $class->force_retarget($flag_ids, $bug)); + return @removed; +} + +sub force_retarget { + my ($class, $flag_ids, $bug) = @_; + my $dbh = Bugzilla->dbh; + + my $flags = $class->new_from_list($flag_ids); + my @removed; + foreach my $flag (@$flags) { + # $bug is undefined when e.g. editing inclusion and exclusion lists. + my $obj = $flag->attachment || $bug || $flag->bug; + my $is_retargetted = $flag->retarget($obj); + if ($is_retargetted) { + $dbh->do('UPDATE flags SET type_id = ? WHERE id = ?', + undef, ($flag->type_id, $flag->id)); + } + else { + # Track deleted attachment flags. + push(@removed, $class->snapshot([$flag])) if $flag->attach_id; + $class->notify(undef, $flag, $bug || $flag->bug); + $flag->remove_from_db(); + } + } + return @removed; +} + +############################### +#### Validators ###### +############################### + +sub _set_requestee { + my ($self, $requestee, $attachment, $skip_requestee_on_error) = @_; + + # Used internally to check if the requestee is retargetting the request. + $self->{_old_requestee_id} = $self->requestee ? $self->requestee->id : 0; + $self->{requestee} = + $self->_check_requestee($requestee, $attachment, $skip_requestee_on_error); + + $self->{requestee_id} = + $self->{requestee} ? $self->{requestee}->id : undef; +} + +sub _set_setter { + my ($self, $setter) = @_; + + $self->set('setter', $setter); + $self->{setter_id} = $self->setter->id; +} + +sub _set_status { + my ($self, $status) = @_; + + # Store the old flag status. It's needed by _check_setter(). + $self->{_old_status} = $self->status; + $self->set('status', $status); +} + +sub _check_requestee { + my ($self, $requestee, $attachment, $skip_requestee_on_error) = @_; + + # If the flag status is not "?", then no requestee can be defined. + return undef if ($self->status ne '?'); + + # Store this value before updating the flag object. + my $old_requestee = $self->requestee ? $self->requestee->login : ''; + + if ($self->status eq '?' && $requestee) { + $requestee = Bugzilla::User->check($requestee); + } + else { + undef $requestee; + } + + if ($requestee && $requestee->login ne $old_requestee) { + # Make sure the user didn't specify a requestee unless the flag + # is specifically requestable. For existing flags, if the requestee + # was set before the flag became specifically unrequestable, the + # user can either remove him or leave him alone. + ThrowCodeError('flag_requestee_disabled', { type => $self->type }) + if !$self->type->is_requesteeble; + + # Make sure the requestee can see the bug. + # Note that can_see_bug() will query the DB, so if the bug + # is being added/removed from some groups and these changes + # haven't been committed to the DB yet, they won't be taken + # into account here. In this case, old restrictions matters. + if (!$requestee->can_see_bug($self->bug_id)) { + if (Bugzilla->params->{auto_add_flag_requestees_to_cc}) + { + # CustIS Bug 55712 - Add flag requestees to CC list + Bugzilla->cgi->param(-name => 'newcc', -value => [ Bugzilla->cgi->param('newcc'), $requestee->login ]); + } + elsif ($skip_requestee_on_error) { + undef $requestee; + } + else { + ThrowUserError('flag_requestee_unauthorized', + { flag_type => $self->type, + requestee => $requestee, + bug_id => $self->bug_id, + attach_id => $self->attach_id }); + } + } + # Make sure the requestee can see the private attachment. + elsif ($self->attach_id && $attachment->isprivate && !$requestee->is_insider) { + if ($skip_requestee_on_error) { + undef $requestee; + } + else { + ThrowUserError('flag_requestee_unauthorized_attachment', + { flag_type => $self->type, + requestee => $requestee, + bug_id => $self->bug_id, + attach_id => $self->attach_id }); + } + } + # Make sure the user is allowed to set the flag. + elsif (!$requestee->can_set_flag($self->type)) { + if ($skip_requestee_on_error) { + undef $requestee; + } + else { + ThrowUserError('flag_requestee_needs_privs', + {'requestee' => $requestee, + 'flagtype' => $self->type}); + } + } + } + return $requestee; +} + +sub _check_setter { + my ($self, $setter) = @_; + + # By default, the currently logged in user is the setter. + $setter ||= Bugzilla->user; + (blessed($setter) && $setter->isa('Bugzilla::User') && $setter->id) + || ThrowCodeError('invalid_user'); + + # set_status() has already been called. So this refers + # to the new flag status. + my $status = $self->status; + + # Make sure the user is authorized to modify flags, see bug 180879: + # - The flag exists and is unchanged. + # - The flag setter can unset flag. + # - Users in the request_group can clear pending requests and set flags + # and can rerequest set flags. + # - Users in the grant_group can set/clear flags, including "+" and "-". + unless (($status eq $self->{_old_status}) + || ($status eq 'X' && $setter->id == Bugzilla->user->id) + || (($status eq 'X' || $status eq '?') + && $setter->can_request_flag($self->type)) + || $setter->can_set_flag($self->type)) + { + ThrowUserError('flag_update_denied', + { name => $self->type->name, + status => $status, + old_status => $self->{_old_status} }); + } + + # If the requester is retargetting the request, we don't + # update the setter, so that the setter gets the notification. + if ($status eq '?' && $self->{_old_requestee_id} == $setter->id) { + return $self->setter; + } + return $setter; +} + +sub _check_status { + my ($self, $status) = @_; + + # - Make sure the status is valid. + # - Make sure the user didn't request the flag unless it's requestable. + # If the flag existed and was requested before it became unrequestable, + # leave it as is. + if (!grep($status eq $_ , qw(X + - ?)) + || ($status eq '?' && $self->status ne '?' && !$self->type->is_requestable)) + { + ThrowCodeError('flag_status_invalid', { id => $self->id, + status => $status }); + } + return $status; +} + +###################################################################### +# Utility Functions +###################################################################### + =pod =over -=item C +=item C -Creates a flag record in the database. +Checks whether or not there are new flags to create and returns an +array of hashes. This array is then passed to Flag::create(). =back =cut -sub create { - my ($flag, $bug, $attachment, $timestamp) = @_; - my $dbh = Bugzilla->dbh; +sub extract_flags_from_cgi { + my ($class, $bug, $attachment, $vars, $skip) = @_; + my $cgi = Bugzilla->cgi; - my $attach_id = $attachment ? $attachment->id : undef; - my $requestee_id; - # Be careful! At this point, $flag is *NOT* yet an object! - $requestee_id = $flag->{'requestee'}->id if $flag->{'requestee'}; + my $match_status = Bugzilla::User::match_field({ + '^requestee(_type)?-(\d+)$' => { 'type' => 'multi' }, + }, undef, $skip); - $dbh->do('INSERT INTO flags (type_id, bug_id, attach_id, requestee_id, - setter_id, status, creation_date, modification_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - undef, ($flag->{'type'}->id, $bug->bug_id, - $attach_id, $requestee_id, $flag->{'setter'}->id, - $flag->{'status'}, $timestamp, $timestamp)); - - # Now that the new flag has been added to the DB, create a real flag object. - # This is required to call notify() correctly. - my $flag_id = $dbh->bz_last_key('flags', 'id'); - $flag = new Bugzilla::Flag($flag_id); - - # Send an email notifying the relevant parties about the flag creation. - if ($flag->requestee && $flag->requestee->wants_mail([EVT_FLAG_REQUESTED])) { - $flag->{'addressee'} = $flag->requestee; + $vars->{'match_field'} = 'requestee'; + if ($match_status == USER_MATCH_FAILED) { + $vars->{'message'} = 'user_match_failed'; + } + elsif ($match_status == USER_MATCH_MULTIPLE) { + $vars->{'message'} = 'user_match_multiple'; } - notify($flag, $bug, $attachment); + # Extract a list of flag type IDs from field names. + my @flagtype_ids = map(/^flag_type-(\d+)$/ ? $1 : (), $cgi->param()); + @flagtype_ids = grep($cgi->param("flag_type-$_") ne 'X', @flagtype_ids); - # Return the new flag object. - return $flag; -} + # Extract a list of existing flag IDs. + my @flag_ids = map(/^flag-(\d+)$/ ? $1 : (), $cgi->param()); -=pod + return () if (!scalar(@flagtype_ids) && !scalar(@flag_ids)); -=over - -=item C - -Modifies flags in the database when a user changes them. - -=back - -=cut - -sub modify { - my ($bug, $attachment, $cgi, $timestamp) = @_; - my $setter = Bugzilla->user; - my $dbh = Bugzilla->dbh; - - # Extract a list of flags from the form data. - my @ids = map(/^flag-(\d+)$/ ? $1 : (), $cgi->param()); - - # Loop over flags and update their record in the database if necessary. - # Two kinds of changes can happen to a flag: it can be set to a different - # state, and someone else can be asked to set it. We take care of both - # those changes. - my @flags; - foreach my $id (@ids) { - my $flag = new Bugzilla::Flag($id); + my (@new_flags, @flags); + foreach my $flag_id (@flag_ids) { + my $flag = $class->new($flag_id); # If the flag no longer exists, ignore it. next unless $flag; - my $status = $cgi->param("flag-$id"); + my $status = $cgi->param("flag-$flag_id"); # If the user entered more than one name into the requestee field # (i.e. they want more than one person to set the flag) we can reuse # the existing flag for the first person (who may well be the existing - # requestee), but we have to create new flags for each additional. - my @requestees = $cgi->param("requestee-$id"); + # requestee), but we have to create new flags for each additional requestee. + my @requestees = $cgi->param("requestee-$flag_id"); my $requestee_email; if ($status eq "?" && scalar(@requestees) > 1 @@ -747,282 +847,39 @@ sub modify { # Create new flags like the existing one for each additional person. foreach my $login (@requestees) { - create({ type => $flag->type, - setter => $setter, - status => "?", - requestee => new Bugzilla::User({ name => $login }) }, - $bug, $attachment, $timestamp); + push(@new_flags, { type_id => $flag->type_id, + status => "?", + requestee => $login, + skip_roe => $skip }); } } - else { - $requestee_email = trim($cgi->param("requestee-$id") || ''); + elsif ($status eq "?" && scalar(@requestees)) { + # If there are several requestees and the flag type is not multiplicable, + # this will fail. But that's the job of the validator to complain. All + # we do here is to extract and convert data from the CGI. + $requestee_email = trim($cgi->param("requestee-$flag_id") || ''); } - # Ignore flags the user didn't change. There are two components here: - # either the status changes (trivial) or the requestee changes. - # Change of either field will cause full update of the flag. - - my $status_changed = ($status ne $flag->status); - - # Requestee is considered changed, if all of the following apply: - # 1. Flag status is '?' (requested) - # 2. Flag can have a requestee - # 3. The requestee specified on the form is different from the - # requestee specified in the db. - - my $old_requestee = $flag->requestee ? $flag->requestee->login : ''; - - my $requestee_changed = - ($status eq "?" && - $flag->type->is_requesteeble && - $old_requestee ne $requestee_email); - - next unless ($status_changed || $requestee_changed); - - # Since the status is validated, we know it's safe, but it's still - # tainted, so we have to detaint it before using it in a query. - trick_taint($status); - - if ($status eq '+' || $status eq '-') { - $dbh->do('UPDATE flags - SET setter_id = ?, requestee_id = NULL, - status = ?, modification_date = ? - WHERE id = ?', - undef, ($setter->id, $status, $timestamp, $flag->id)); - - # If the status of the flag was "?", we have to notify - # the requester (if he wants to). - my $requester; - if ($flag->status eq '?') { - $requester = $flag->setter; - $flag->{'requester'} = $requester; - } - # Now update the flag object with its new values. - $flag->{'setter'} = $setter; - $flag->{'requestee'} = undef; - $flag->{'requestee_id'} = undef; - $flag->{'status'} = $status; - - # Send an email notifying the relevant parties about the fulfillment, - # including the requester. - if ($requester && $requester->wants_mail([EVT_REQUESTED_FLAG])) { - $flag->{'addressee'} = $requester; - } - - notify($flag, $bug, $attachment); - } - elsif ($status eq '?') { - # If the one doing the change is the requestee, then this means he doesn't - # want to reply to the request and he simply reassigns the request to - # someone else. In this case, we keep the requester unaltered. - my $new_setter = $setter; - if ($flag->requestee && $flag->requestee->id == $setter->id) { - $new_setter = $flag->setter; - } - - # Get the requestee, if any. - my $requestee_id; - if ($requestee_email) { - $requestee_id = login_to_id($requestee_email); - $flag->{'requestee'} = new Bugzilla::User($requestee_id); - $flag->{'requestee_id'} = $requestee_id; - } - else { - # If the status didn't change but we only removed the - # requestee, we have to clear the requestee field. - $flag->{'requestee'} = undef; - $flag->{'requestee_id'} = undef; - } - - # Update the database with the changes. - $dbh->do('UPDATE flags - SET setter_id = ?, requestee_id = ?, - status = ?, modification_date = ? - WHERE id = ?', - undef, ($new_setter->id, $requestee_id, $status, - $timestamp, $flag->id)); - - # Now update the flag object with its new values. - $flag->{'setter'} = $new_setter; - $flag->{'status'} = $status; - - # Send an email notifying the relevant parties about the request. - if ($flag->requestee && $flag->requestee->wants_mail([EVT_FLAG_REQUESTED])) { - $flag->{'addressee'} = $flag->requestee; - } - - notify($flag, $bug, $attachment); - } - elsif ($status eq 'X') { - clear($flag, $bug, $attachment); - } - - push(@flags, $flag); + push(@flags, { id => $flag_id, + status => $status, + requestee => $requestee_email, + skip_roe => $skip }); } - return \@flags; -} - -=pod - -=over - -=item C - -Change the type of the flag, if possible. The new flag type must have -the same name as the current flag type, must exist in the product and -component the bug is in, and the current settings of the flag must pass -validation. If no such flag type can be found, the type remains unchanged. - -Retargetting flags is a good way to keep flags when moving bugs from one -product where a flag type is available to another product where the flag -type is unavailable, but another flag type having the same name exists. -Most of the time, if they have the same name, this means that they have -the same meaning, but with different settings. - -=back - -=cut - -sub retarget { - my ($flag, $bug) = @_; - my $dbh = Bugzilla->dbh; - - # We are looking for flagtypes having the same name as the flagtype - # to which the current flag belongs, and being in the new product and - # component of the bug. - my $flagtypes = Bugzilla::FlagType::match( - {'name' => $flag->name, - 'target_type' => $flag->type->target_type, - 'is_active' => 1, - 'product_id' => $bug->product_id, - 'component_id' => $bug->component_id}); - - # If we found no flagtype, the flag will be deleted. - return 0 unless scalar(@$flagtypes); - - # If we found at least one, change the type of the flag, - # assuming the setter/requester is allowed to set/request flags - # belonging to this flagtype. - my $requestee = $flag->requestee ? [$flag->requestee->login] : []; - my $is_private = ($flag->attachment) ? $flag->attachment->isprivate : 0; - my $is_retargetted = 0; - - foreach my $flagtype (@$flagtypes) { - # Get the number of flags of this type already set for this target. - my $has_flags = __PACKAGE__->count( - { 'type_id' => $flagtype->id, - 'bug_id' => $bug->bug_id, - 'attach_id' => $flag->attach_id }); - - # Do not create a new flag of this type if this flag type is - # not multiplicable and already has a flag set. - next if (!$flagtype->is_multiplicable && $has_flags); - - # Check user privileges. - my $error_mode_cache = Bugzilla->error_mode; - Bugzilla->error_mode(ERROR_MODE_DIE); - eval { - _validate(undef, $flagtype, $flag->status, $flag->setter, - $requestee, $is_private, $bug->bug_id, $flag->attach_id); - }; - Bugzilla->error_mode($error_mode_cache); - # If the validation failed, then we cannot use this flagtype. - next if ($@); - - # Checks are successful, we can retarget the flag to this flagtype. - $dbh->do('UPDATE flags SET type_id = ? WHERE id = ?', - undef, ($flagtype->id, $flag->id)); - - $is_retargetted = 1; - last; - } - return $is_retargetted; -} - -=pod - -=over - -=item C - -Remove a flag from the DB. - -=back - -=cut - -sub clear { - my ($flag, $bug, $attachment) = @_; - my $dbh = Bugzilla->dbh; - - $dbh->do('DELETE FROM flags WHERE id = ?', undef, $flag->id); - - # If we cancel a pending request, we have to notify the requester - # (if he wants to). - my $requester; - if ($flag->status eq '?') { - $requester = $flag->setter; - $flag->{'requester'} = $requester; - } - - # Now update the flag object to its new values. The last - # requester/setter and requestee are kept untouched (for the - # record). Else we could as well delete the flag completely. - $flag->{'exists'} = 0; - $flag->{'status'} = "X"; - - if ($requester && $requester->wants_mail([EVT_REQUESTED_FLAG])) { - $flag->{'addressee'} = $requester; - } - - notify($flag, $bug, $attachment); -} - - -###################################################################### -# Utility Functions -###################################################################### - -=pod - -=over - -=item C - -Checks whether or not there are new flags to create and returns an -array of flag objects. This array is then passed to Flag::create(). - -=back - -=cut - -sub FormToNewFlags { - my ($bug, $attachment, $cgi, $hr_vars) = @_; - my $dbh = Bugzilla->dbh; - my $setter = Bugzilla->user; - - # Extract a list of flag type IDs from field names. - my @type_ids = map(/^flag_type-(\d+)$/ ? $1 : (), $cgi->param()); - @type_ids = grep($cgi->param("flag_type-$_") ne 'X', @type_ids); - - return () unless scalar(@type_ids); - # Get a list of active flag types available for this product/component. my $flag_types = Bugzilla::FlagType::match( { 'product_id' => $bug->{'product_id'}, 'component_id' => $bug->{'component_id'}, 'is_active' => 1 }); - foreach my $type_id (@type_ids) { + foreach my $flagtype_id (@flagtype_ids) { # Checks if there are unexpected flags for the product/component. - if (!scalar(grep { $_->id == $type_id } @$flag_types)) { - $hr_vars->{'message'} = 'unexpected_flag_types'; + if (!scalar(grep { $_->id == $flagtype_id } @$flag_types)) { + $vars->{'message'} = 'unexpected_flag_types'; last; } } - my @flags; foreach my $flag_type (@$flag_types) { my $type_id = $flag_type->id; @@ -1031,10 +888,10 @@ sub FormToNewFlags { next unless ($flag_type->target_type eq 'bug' xor $attachment); # We are only interested in flags the user tries to create. - next unless scalar(grep { $_ == $type_id } @type_ids); + next unless scalar(grep { $_ == $type_id } @flagtype_ids); # Get the number of flags of this type already set for this target. - my $has_flags = __PACKAGE__->count( + my $has_flags = $class->count( { 'type_id' => $type_id, 'target_type' => $attachment ? 'attachment' : 'bug', 'bug_id' => $bug->bug_id, @@ -1048,25 +905,23 @@ sub FormToNewFlags { trick_taint($status); my @logins = $cgi->param("requestee_type-$type_id"); - if ($status eq "?" && scalar(@logins) > 0) { + if ($status eq "?" && scalar(@logins)) { foreach my $login (@logins) { - push (@flags, { type => $flag_type , - setter => $setter , - status => $status , - requestee => - new Bugzilla::User({ name => $login }) }); + push (@new_flags, { type_id => $type_id, + status => $status, + requestee => $login, + skip_roe => $skip }); last unless $flag_type->is_multiplicable; } } else { - push (@flags, { type => $flag_type , - setter => $setter , - status => $status }); + push (@new_flags, { type_id => $type_id, + status => $status }); } } - # Return the list of flags. - return \@flags; + # Return the list of flags to update and/or to create. + return (\@flags, \@new_flags); } =pod @@ -1083,10 +938,41 @@ or deleted. =cut sub notify { - my ($flag, $bug, $attachment) = @_; + my ($class, $flag, $old_flag, $obj) = @_; - # There is nobody to notify. - return unless ($flag->{'addressee'} || $flag->type->cc_list); + my ($bug, $attachment); + if (blessed($obj) && $obj->isa('Bugzilla::Attachment')) { + $attachment = $obj; + $bug = $attachment->bug; + } + elsif (blessed($obj) && $obj->isa('Bugzilla::Bug')) { + $bug = $obj; + } + else { + # Not a good time to throw an error. + return; + } + + my $addressee; + # If the flag is set to '?', maybe the requestee wants a notification. + if ($flag && $flag->requestee_id + && (!$old_flag || ($old_flag->requestee_id || 0) != $flag->requestee_id)) + { + if ($flag->requestee->wants_mail([EVT_FLAG_REQUESTED])) { + $addressee = $flag->requestee; + } + } + elsif ($old_flag && $old_flag->status eq '?' + && (!$flag || $flag->status ne '?')) + { + if ($old_flag->setter->wants_mail([EVT_REQUESTED_FLAG])) { + $addressee = $old_flag->setter; + } + } + + my $cc_list = $flag ? $flag->type->cc_list : $old_flag->type->cc_list; + # Is there someone to notify? + return unless ($addressee || $cc_list); # If the target bug is restricted to one or more groups, then we need # to make sure we don't send email about it to unauthorized users @@ -1096,7 +982,7 @@ sub notify { my $attachment_is_private = $attachment ? $attachment->isprivate : undef; my %recipients; - foreach my $cc (split(/[, ]+/, $flag->type->cc_list)) { + foreach my $cc (split(/[, ]+/, $cc_list)) { my $ccuser = new Bugzilla::User({ name => $cc }); next if (scalar(@bug_in_groups) && (!$ccuser || !$ccuser->can_see_bug($bug->bug_id))); next if $attachment_is_private && (!$ccuser || !$ccuser->is_insider); @@ -1106,16 +992,15 @@ sub notify { } # Only notify if the addressee is allowed to receive the email. - if ($flag->{'addressee'} && $flag->{'addressee'}->email_enabled) { - $recipients{$flag->{'addressee'}->email} = $flag->{'addressee'}; + if ($addressee && $addressee->email_enabled) { + $recipients{$addressee->email} = $addressee; } # Process and send notification for each recipient. # If there are users in the CC list who don't have an account, # use the default language for email notifications. my $default_lang; if (grep { !$_ } values %recipients) { - my $default_user = new Bugzilla::User(); - $default_lang = $default_user->settings->{'lang'}->{'value'}; + $default_lang = Bugzilla::User->new()->settings->{'lang'}->{'value'}; } foreach my $to (keys %recipients) { @@ -1124,6 +1009,7 @@ sub notify { my $thread_user_id = $recipients{$to} ? $recipients{$to}->id : 0; my $vars = { 'flag' => $flag, + 'old_flag' => $old_flag, 'to' => $to, 'bug' => $bug, 'attachment' => $attachment, @@ -1142,43 +1028,12 @@ sub notify { } } -# Cancel all request flags from the attachment being obsoleted. -sub CancelRequests { - my ($class, $bug, $attachment, $timestamp) = @_; - my $dbh = Bugzilla->dbh; - - my $request_ids = - $dbh->selectcol_arrayref("SELECT flags.id - FROM flags - LEFT JOIN attachments ON flags.attach_id = attachments.attach_id - WHERE flags.attach_id = ? - AND flags.status = '?' - AND attachments.isobsolete = 0", - undef, $attachment->id); - - return if (!scalar(@$request_ids)); - - # Take a snapshot of flags before any changes. - my @old_summaries = $class->snapshot($bug->bug_id, $attachment->id) - if ($timestamp); - my $flags = Bugzilla::Flag->new_from_list($request_ids); - foreach my $flag (@$flags) { clear($flag, $bug, $attachment) } - - # If $timestamp is undefined, do not update the activity table - return unless ($timestamp); - - # Take a snapshot of flags after any changes. - my @new_summaries = $class->snapshot($bug->bug_id, $attachment->id); - update_activity($bug->bug_id, $attachment->id, $timestamp, - \@old_summaries, \@new_summaries); -} - # This is an internal function used by $bug->flag_types # and $attachment->flag_types to collect data about available # flag types and existing flags set on them. You should never # call this function directly. sub _flag_types { - my $vars = shift; + my ($class, $vars) = @_; my $target_type = $vars->{target_type}; my $flags; @@ -1186,29 +1041,31 @@ sub _flag_types { # Retrieve all existing flags for this bug/attachment. if ($target_type eq 'bug') { my $bug_id = delete $vars->{bug_id}; - $flags = Bugzilla::Flag->match({target_type => 'bug', bug_id => $bug_id}); + $flags = $class->match({target_type => 'bug', bug_id => $bug_id}); } elsif ($target_type eq 'attachment') { my $attach_id = delete $vars->{attach_id}; - $flags = Bugzilla::Flag->match({attach_id => $attach_id}); + $flags = $class->match({attach_id => $attach_id}); } else { ThrowCodeError('bad_arg', {argument => 'target_type', - function => 'Bugzilla::Flag::_flag_types'}); + function => $class . '->_flag_types'}); } # Get all available flag types for the given product and component. - my $flag_types = Bugzilla::FlagType::match($vars); + my $cache = Bugzilla->request_cache->{flag_types_per_component}->{$vars->{target_type}} ||= {}; + my $flag_data = $cache->{$vars->{component_id}} ||= Bugzilla::FlagType::match($vars); + my $flag_types = dclone($flag_data); $_->{flags} = [] foreach @$flag_types; my %flagtypes = map { $_->id => $_ } @$flag_types; - # Group existing flags per type. - # Call the internal 'type_id' variable instead of the method - # to not create a flagtype object. - push(@{$flagtypes{$_->{type_id}}->{flags}}, $_) foreach @$flags; - - return [sort {$a->sortkey <=> $b->sortkey || $a->name cmp $b->name} values %flagtypes]; + # Group existing flags per type, and skip those becoming invalid + # (which can happen when a bug is being moved into a new product + # or component). + @$flags = grep { exists $flagtypes{$_->type_id} } @$flags; + push(@{$flagtypes{$_->type_id}->{flags}}, $_) foreach @$flags; + return $flag_types; } =head1 SEE ALSO diff --git a/Bugzilla/Group.pm b/Bugzilla/Group.pm index 2e8a975d2..65df4ee81 100644 --- a/Bugzilla/Group.pm +++ b/Bugzilla/Group.pm @@ -84,21 +84,49 @@ sub user_regexp { return $_[0]->{'userregexp'}; } sub is_active { return $_[0]->{'isactive'}; } sub icon_url { return $_[0]->{'icon_url'}; } +sub bugs { + my $self = shift; + return $self->{bugs} if exists $self->{bugs}; + my $bug_ids = Bugzilla->dbh->selectcol_arrayref( + 'SELECT bug_id FROM bug_group_map WHERE group_id = ?', + undef, $self->id); + require Bugzilla::Bug; + $self->{bugs} = Bugzilla::Bug->new_from_list($bug_ids); + return $self->{bugs}; +} + sub members_direct { my ($self) = @_; - return $self->{members_direct} if defined $self->{members_direct}; - my $dbh = Bugzilla->dbh; - my $user_ids = $dbh->selectcol_arrayref( - "SELECT user_group_map.user_id - FROM user_group_map - WHERE user_group_map.group_id = ? - AND grant_type = " . GRANT_DIRECT . " - AND isbless = 0", undef, $self->id); - require Bugzilla::User; - $self->{members_direct} = Bugzilla::User->new_from_list($user_ids); + $self->{members_direct} ||= $self->_get_members(GRANT_DIRECT); return $self->{members_direct}; } +sub members_non_inherited { + my ($self) = @_; + $self->{members_non_inherited} ||= $self->_get_members(); + return $self->{members_non_inherited}; +} + +# A helper for members_direct and members_non_inherited +sub _get_members { + my ($self, $grant_type) = @_; + my $dbh = Bugzilla->dbh; + my $grant_clause = $grant_type ? "AND grant_type = $grant_type" : ""; + my $user_ids = $dbh->selectcol_arrayref( + "SELECT DISTINCT user_id + FROM user_group_map + WHERE isbless = 0 $grant_clause AND group_id = ?", undef, $self->id); + require Bugzilla::User; + return Bugzilla::User->new_from_list($user_ids); +} + +sub flag_types { + my $self = shift; + require Bugzilla::FlagType; + $self->{flag_types} ||= Bugzilla::FlagType::match({ group => $self->id }); + return $self->{flag_types}; +} + sub grant_direct { my ($self, $type) = @_; $self->{grant_direct} ||= {}; @@ -131,6 +159,30 @@ sub granted_by_direct { return $self->{granted_by_direct}->{$type}; } +sub products { + my $self = shift; + return $self->{products} if exists $self->{products}; + my $product_data = Bugzilla->dbh->selectall_arrayref( + 'SELECT product_id, entry, membercontrol, othercontrol, + canedit, editcomponents, editbugs, canconfirm + FROM group_control_map WHERE group_id = ?', {Slice=>{}}, + $self->id); + my @ids = map { $_->{product_id} } @$product_data; + require Bugzilla::Product; + my $products = Bugzilla::Product->new_from_list(\@ids); + my %data_map = map { $_->{product_id} => $_ } @$product_data; + my @retval; + foreach my $product (@$products) { + # Data doesn't need to contain product_id--we already have + # the product object. + delete $data_map{$product->id}->{product_id}; + push(@retval, { controls => $data_map{$product->id}, + product => $product }); + } + $self->{products} = \@retval; + return $self->{products}; +} + ############################### #### Methods #### ############################### @@ -143,6 +195,8 @@ sub set_icon_url { $_[0]->set('icon_url', $_[1]); } sub update { my $self = shift; + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); my $changes = $self->SUPER::update(@_); if (exists $changes->{name}) { @@ -162,9 +216,76 @@ sub update { && $changes->{isactive}->[1]); $self->_rederive_regexp() if exists $changes->{userregexp}; + + Bugzilla::Hook::process('group_end_of_update', + { group => $self, changes => $changes }); + $dbh->bz_commit_transaction(); return $changes; } +sub check_remove { + my ($self, $params) = @_; + + # System groups cannot be deleted! + if (!$self->is_bug_group) { + ThrowUserError("system_group_not_deletable", { name => $self->name }); + } + + # Groups having a special role cannot be deleted. + my @special_groups; + foreach my $special_group (GROUP_PARAMS) { + if ($self->name eq Bugzilla->params->{$special_group}) { + push(@special_groups, $special_group); + } + } + if (scalar(@special_groups)) { + ThrowUserError('group_has_special_role', + { name => $self->name, + groups => \@special_groups }); + } + + return if $params->{'test_only'}; + + my $cantdelete = 0; + + my $users = $self->members_non_inherited; + if (scalar(@$users) && !$params->{'remove_from_users'}) { + $cantdelete = 1; + } + + my $bugs = $self->bugs; + if (scalar(@$bugs) && !$params->{'remove_from_bugs'}) { + $cantdelete = 1; + } + + my $products = $self->products; + if (scalar(@$products) && !$params->{'remove_from_products'}) { + $cantdelete = 1; + } + + my $flag_types = $self->flag_types; + if (scalar(@$flag_types) && !$params->{'remove_from_flags'}) { + $cantdelete = 1; + } + + ThrowUserError('group_cannot_delete', { group => $self }) if $cantdelete; +} + +sub remove_from_db { + my $self = shift; + my $dbh = Bugzilla->dbh; + $self->check_remove(@_); + $dbh->bz_start_transaction(); + Bugzilla::Hook::process('group_before_delete', { group => $self }); + $dbh->do('DELETE FROM whine_schedules + WHERE mailto_type = ? AND mailto = ?', + undef, MAILTO_GROUP, $self->id); + # All the other tables will be handled by foreign keys when we + # drop the main "groups" row. + $self->SUPER::remove_from_db(@_); + $dbh->bz_commit_transaction(); +} + # Add missing entries in bug_group_map for bugs created while # a mandatory group was disabled and which is now enabled again. sub _enforce_mandatory { @@ -224,20 +345,6 @@ sub _rederive_regexp { } } -sub members_non_inherited { - my ($self) = @_; - return $self->{members_non_inherited} - if exists $self->{members_non_inherited}; - - my $member_ids = Bugzilla->dbh->selectcol_arrayref( - 'SELECT DISTINCT user_id FROM user_group_map - WHERE isbless = 0 AND group_id = ?', - undef, $self->id) || []; - require Bugzilla::User; - $self->{members_non_inherited} = Bugzilla::User->new_from_list($member_ids); - return $self->{members_non_inherited}; -} - sub flatten_group_membership { my ($self, @groups) = @_; @@ -277,6 +384,8 @@ sub create { print get_text('install_group_create', { name => $params->{name} }) . "\n" if Bugzilla->usage_mode == USAGE_MODE_CMDLINE; + $dbh->bz_start_transaction(); + my $group = $class->SUPER::create(@_); # Since we created a new group, give the "admin" group all privileges @@ -294,6 +403,9 @@ sub create { } $group->_rederive_regexp() if $group->user_regexp; + + Bugzilla::Hook::process('group_end_of_create', { group => $group }); + $dbh->bz_commit_transaction(); return $group; } @@ -414,6 +526,53 @@ be a member of this group. =over +=item C + +=over + +=item B + +Determines whether it's OK to remove this group from the database, and +throws an error if it's not OK. + +=item B + +=over + +=item C + +C If you want to only check if the group can be deleted I, +under any circumstances, specify C to just do the most basic tests +(the other parameters will be ignored in this situation, as those tests won't +be run). + +=item C + +C True if it would be OK to remove all users who are in this group +from this group. + +=item C + +C True if it would be OK to remove all bugs that are in this group +from this group. + +=item C + +C True if it would be OK to stop all flagtypes that reference +this group from referencing this group (e.g., as their grantgroup or +requestgroup). + +=item C + +C True if it would be OK to remove this group from all group controls +on products. + +=back + +=item B (nothing) + +=back + =item C Returns an arrayref of L objects representing people who are diff --git a/Bugzilla/Hook.pm b/Bugzilla/Hook.pm index 6e77defd8..e800ffcf3 100644 --- a/Bugzilla/Hook.pm +++ b/Bugzilla/Hook.pm @@ -18,69 +18,112 @@ # Rights Reserved. # # Contributor(s): Zach Lipton -# +# Max Kanat-Alexander package Bugzilla::Hook; -use Bugzilla::Constants; -use Bugzilla::Util; -use Bugzilla::Error; - use strict; +no strict 'subs'; +use Bugzilla::Util; +use base 'Exporter'; +our @EXPORT = qw(set_hook run_hooks); -sub process { - my ($name, $args) = @_; - - # get a list of all extensions - my @extensions = glob(bz_locations()->{'extensionsdir'} . "/*"); - - # check each extension to see if it uses the hook - # if so, invoke the extension source file: - foreach my $extension (@extensions) { - # all of these variables come directly from code or directory names. - # If there's malicious data here, we have much bigger issues to - # worry about, so we can safely detaint them: - trick_taint($extension); - # Skip CVS directories and any hidden files/dirs. - next if $extension =~ m{/CVS$} || $extension =~ m{/\.[^/]+$}; - next if -e "$extension/disabled"; - if (-e $extension.'/code/'.$name.'.pl') { - Bugzilla->hook_args($args); - # Allow extensions to load their own libraries. - local @INC = ("$extension/lib", @INC); - do($extension.'/code/'.$name.'.pl'); - if ($@) - { - $@->throw if ref($@) && $@->isa('Bugzilla::Error'); - ThrowCodeError('extension_invalid', - { errstr => $@, name => $name, extension => $extension }); - } - # Flush stored data. - Bugzilla->hook_args({}); - } - } +my %hooks; +my @hook_stack; +my %hook_hash; + +# Set extension hook or hooks +sub set_hook +{ + my ($extension, $hook, $callable) = @_; + $hook =~ tr/-/_/; + $hooks{$hook}{$extension} = $callable; } -sub enabled_plugins { - my $extdir = bz_locations()->{'extensionsdir'}; - my @extensions = glob("$extdir/*"); - my %enabled; - foreach my $extension (@extensions) { - trick_taint($extension); - my $extname = $extension; - $extname =~ s{^\Q$extdir\E/}{}; - next if $extname eq 'CVS' || $extname =~ /^\./; - next if -e "$extension/disabled"; - # Allow extensions to load their own libraries. - local @INC = ("$extension/lib", @INC); - $enabled{$extname} = do("$extension/info.pl"); - ThrowCodeError('extension_invalid', - { errstr => $@, name => 'version', - extension => $extension }) if $@; +# An alias +sub run_hooks +{ + goto &process; +} +# Process all hooks with name $name +sub process +{ + my ($name, $args) = @_; + $name =~ tr/-/_/; + + push @hook_stack, $name; + $hook_hash{$name}++; + + my @process = values %{$hooks{$name}}; + for my $f (@process) + { + if (!defined $f) + { + next; + } + elsif (ref $f eq 'ARRAY') + { + push @process, @$f; + next; + } + elsif (ref $f eq 'CODE') + { + # Fall through if() + } + elsif (!ref $f && $f =~ /^(.*)::[^:]*$/) + { + my $pk = $1; + if ($pk) + { + eval { require $pk }; + if ($@) + { + warn "Error autoloading hook package $pk: $@"; + } + } + } + elsif (ref $f eq 'HASH' && $f->{type} eq 'file' && + open my $fd, $f->{filename}) + { + # Slurp file content into $hook_sub + my $sub; + { + local $/ = undef; + $sub = <$fd>; + trick_taint($sub); + } + close $fd; + $sub =~ s/Bugzilla->hook_args/\$args/gso; + my $pk = $f->{filename}; + $pk =~ s/\W+/_/gso; + $pk = "Bugzilla::Hook::$pk"; + $sub = eval "package $pk; sub { my (\$args) = \@_; $sub; return 1; };"; + if ($@) + { + warn __PACKAGE__."::load(): error during loading $f->{filename} into a subroutine (note that Bugzilla->hook_args was replaced by \$args): $@"; + next; + } + $f = $sub; + } + else + { + die "Don't know what to do with hook callable \"$f\". Is it really callable?"; + } + # OK, call the function! + # When a hook returns TRUE, other hooks are also called + # When a hook returns FALSE, hook processing is stopped + &$f($args) || last; } - return \%enabled; + $hook_hash{$name}--; + pop @hook_stack; +} + +sub in +{ + my $hook_name = shift; + return $hook_hash{$hook_name} || 0; } 1; @@ -105,36 +148,19 @@ hooks. When a piece of standard Bugzilla code wants to allow an extension to perform additional functions, it uses Bugzilla::Hook's L subroutine to invoke any extension code if installed. -There is a sample extension in F that demonstrates -most of the things described in this document, as well as many of the -hooks available. +The implementation of extensions is described in L. + +There is sample code for every hook in the Example extension, located in +F. =head2 How Hooks Work -When a hook named C is run, Bugzilla will attempt to invoke any -source files named F. +When a hook named C is run, Bugzilla looks through all +enabled L for extensions that implement +a subroutined named C. -So, for example, if your extension is called "testopia", and you -want to have code run during the L hook, you -would have a file called F -that contained perl code to run during that hook. - -=head2 Arguments Passed to Hooks - -Some L have params that are passed to them. - -These params are accessible through L. -That returns a hashref. Very frequently, if you want your -hook to do anything, you have to modify these variables. - -=head2 Versioning Extensions - -Every extension must have a file in its root called F. -This file must return a hash when called with C. -The hash must contain a 'version' key with the current version of the -extension. Extension authors can also add any extra infomration to this hash if -required, by adding a new key beginning with x_ which will not be used the -core Bugzilla code. +See L for more details about how an extension +can run code during a hook. =head1 SUBROUTINES @@ -174,7 +200,25 @@ This describes what hooks exist in Bugzilla currently. They are mostly in alphabetical order, but some related hooks are near each other instead of being alphabetical. -=head2 auth-login_methods +=head2 attachment_process_data + +This happens at the very beginning process of the attachment creation. +You can edit the attachment content itself as well as all attributes +of the attachment, before they are validated and inserted into the DB. + +Params: + +=over + +=item C - A reference pointing either to the content of the file +being uploaded or pointing to the filehandle associated with the file. + +=item C - A hashref whose keys are the same as +L. The data it contains hasn't been checked yet. + +=back + +=head2 auth_login_methods This allows you to add new login types to Bugzilla. (See L.) @@ -205,16 +249,16 @@ login methods that weren't passed to L.) =back -=head2 auth-verify_methods +=head2 auth_verify_methods -This works just like L except it's for +This works just like L except it's for login verification methods (See L.) It also -takes a C parameter, just like L. +takes a C parameter, just like L. -=head2 bug-columns +=head2 bug_columns This allows you to add new fields that will show up in every L -object. Note that you will also need to use the L hook in +object. Note that you will also need to use the L hook in conjunction with this hook to make this work. Params: @@ -226,7 +270,7 @@ your column name(s) onto the array. =back -=head2 bug-end_of_create +=head2 bug_end_of_create This happens at the end of L, after all other changes are made to the database. This occurs inside a database transaction. @@ -242,7 +286,22 @@ values. =back -=head2 bug-end_of_update +=head2 bug_end_of_create_validators + +This happens during L, after all parameters have +been validated, but before anything has been inserted into the database. + +Params: + +=over + +=item C + +A hashref. The validated parameters passed to C. + +=back + +=head2 bug_end_of_update This happens at the end of L, after all other changes are made to the database. This generally occurs inside a database transaction. @@ -251,23 +310,32 @@ Params: =over -=item C - The changed bug object, with all fields set to their updated -values. +=item C -=item C - The timestamp used for all updates in this transaction. +The changed bug object, with all fields set to their updated values. -=item C - The hash of changed fields. -C<$changes-E{field} = [old, new]> +=item C + +A bug object pulled from the database before the fields were set to +their updated values (so it has the old values available for each field). + +=item C + +The timestamp used for all updates in this transaction. + +=item C + +The hash of changed fields. C<< $changes->{field} = [old, new] >> =back -=head2 bug-fields +=head2 bug_fields Allows the addition of database fields from the bugs table to the standard list of allowable fields in a L object, so that you can call the field as a method. -Note: You should add here the names of any fields you added in L. +Note: You should add here the names of any fields you added in L. Params: @@ -278,7 +346,72 @@ your column name(s) onto the array. =back -=head2 buglist-columns +=head2 bug_format_comment + +Allows you to do custom parsing on comments before they are displayed. You do +this by returning two regular expressions: one that matches the section you +want to replace, and then another that says what you want to replace that +match with. + +The matching and replacement will be run with the C switch on the regex. + +Params: + +=over + +=item C + +An arrayref of hashrefs. + +You should push a hashref containing two keys (C and C) +in to this array. C is the regular expression that matches the +text you want to replace, C is what you want to replace that +text with. (This gets passed into a regular expression like +C.) + +Instead of specifying a regular expression for C you can also +return a coderef (a reference to a subroutine). If you want to use +backreferences (using C<$1>, C<$2>, etc. in your C), you have to use +this method--it won't work if you specify C<$1>, C<$2> in a regular expression +for C. Your subroutine will get a hashref as its only argument. This +hashref contains a single key, C. C is an arrayref that +contains C<$1>, C<$2>, C<$3>, etc. in order, up to C<$10>. Your subroutine +should return what you want to replace the full C with. (See the code +example for this hook if you want to see how this actually all works in code. +It's simpler than it sounds.) + +B Failing to +do so could open a security hole in Bugzilla. + +=item C + +A B to the exact text that you are parsing. + +Generally you should not modify this yourself. Instead you should be +returning regular expressions using the C array. + +The text has already been word-wrapped, but has not been parsed in any way +otherwise. (So, for example, it is not HTML-escaped. You get "&", not +"&".) + +=item C + +The L object that this comment is on. Sometimes this is +C, meaning that we are parsing text that is not on a bug. + +=item C + +A hashref representing the comment you are about to parse, including +all of the fields that comments contain when they are returned by +by L. + +Sometimes this is C, meaning that we are parsing text that is +not a bug comment (but could still be some other part of a bug, like +the summary line). + +=back + +=head2 buglist_columns This happens in buglist.cgi after the standard columns have been defined and right before the display column determination. It gives you the opportunity @@ -306,7 +439,49 @@ The definition is structured as: =back -=head2 colchange-columns +=head2 bugmail_recipients + +This allows you to modify the list of users who are going to be receiving +a particular bugmail. It also allows you to specify why they are receiving +the bugmail. + +Users' bugmail preferences will be applied to any users that you add +to the list. (So, for example, if you add somebody as though they were +a CC on the bug, and their preferences state that they don't get email +when they are a CC, they won't get email.) + +This hook is called before watchers or globalwatchers are added to the +recipient list. + +Params: + +=over + +=item C + +The L that bugmail is being sent about. + +=item C + +This is a hashref. The keys are numeric user ids from the C +table in the database, for each user who should be receiving this bugmail. +The values are hashrefs. The keys in I hashrefs correspond to +the "relationship" that the user has to the bug they're being emailed +about, and the value should always be C<1>. The "relationships" +are described by the various C constants in L. + +Here's an example of adding userid C<123> to the recipient list +as though he were on the CC list: + + $recipients->{123}->{+REL_CC} = 1 + +(We use C<+> in front of C so that Perl interprets it as a constant +instead of as a string.) + +=back + + +=head2 colchange_columns This happens in F right after the list of possible display columns have been defined and gives you the opportunity to add additional @@ -317,12 +492,11 @@ Params: =over =item C - An arrayref containing an array of column IDs. Any IDs -added by this hook must have been defined in the the buglist-columns hook. -See L. +added by this hook must have been defined in the the L hook. =back -=head2 config-add_panels +=head2 config_add_panels If you want to add new panels to the Parameters administrative interface, this is where you do it. @@ -343,7 +517,7 @@ extension.) =back -=head2 config-modify_panels +=head2 config_modify_panels This is how you modify already-existing panels in the Parameters administrative interface. For example, if you wanted to add a new @@ -363,11 +537,13 @@ C for that module. You can modify C and your changes will be reflected in the interface. Adding new keys to C will have no effect. You should use -L if you want to add new panels. +L if you want to add new panels. =back -=head2 enter_bug-entrydefaultvars +=head2 enter_bug_entrydefaultvars + +B - Use L instead. This happens right before the template is loaded on enter_bug.cgi. @@ -379,9 +555,9 @@ Params: =back -=head2 flag-end_of_update +=head2 flag_end_of_update -This happens at the end of L, after all other changes +This happens at the end of L, after all other changes are made to the database and after emails are sent. It gives you a before/after snapshot of flags so you can react to specific flag changes. This generally occurs inside a database transaction. @@ -393,7 +569,7 @@ Params: =over -=item C - The changed bug object. +=item C - The changed bug or attachment object. =item C - The timestamp used for all updates in this transaction. @@ -405,7 +581,53 @@ changed flags, and search for a specific condition like C. =back -=head2 install-before_final_checks +=head2 group_before_delete + +This happens in L, after we've confirmed +that the group can be deleted, but before any rows have actually +been removed from the database. This occurs inside a database +transaction. + +Params: + +=over + +=item C - The L being deleted. + +=back + +=head2 group_end_of_create + +This happens at the end of L, after all other +changes are made to the database. This occurs inside a database transaction. + +Params: + +=over + +=item C - The changed L object, with all fields set +to their updated values. + +=back + +=head2 group_end_of_update + +This happens at the end of L, after all other +changes are made to the database. This occurs inside a database transaction. + +Params: + +=over + +=item C - The changed L object, with all fields set +to their updated values. + +=item C - The hash of changed fields. +C<< $changes->{$field} = [$old, $new] >> + +=back + +=head2 install_before_final_checks Allows execution of custom code before the final checks are done in checksetup.pl. @@ -420,44 +642,20 @@ A flag that indicates whether or not checksetup is running in silent mode. =back -=head2 install-requirements - -Because of the way Bugzilla installation works, there can't be a normal -hook during the time that F checks what modules are -installed. (C needs to have those modules installed--it's -a chicken-and-egg problem.) - -So instead of the way hooks normally work, this hook just looks for two -subroutines (or constants, since all constants are just subroutines) in -your file, called C and C, -which should return arrayrefs in the same format as C and -C in L. - -These subroutines will be passed an arrayref that contains the current -Bugzilla requirements of the same type, in case you want to modify -Bugzilla's requirements somehow. (Probably the most common would be to -alter a version number or the "feature" element of C.) - -F will add these requirements to its own. - -Please remember--if you put something in C, then -F B unless the user has that module -installed! So use C whenever you can. - -=head2 install-update_db +=head2 install_update_db This happens at the very end of all the tables being updated during an installation or upgrade. If you need to modify your custom schema, do it here. No params are passed. -=head2 db_schema-abstract_schema +=head2 db_schema_abstract_schema This allows you to add tables to Bugzilla. Note that we recommend that you prefix the names of your tables with some word, so that they don't conflict with any future Bugzilla tables. If you wish to add new I to existing Bugzilla tables, do that -in L. +in L. Params: @@ -470,7 +668,7 @@ database when run. =back -=head2 mailer-before_send +=head2 mailer_before_send Called right before L sends a message to the MTA. @@ -485,7 +683,173 @@ L. =back -=head2 page-before_template +=head2 object_before_create + +This happens at the beginning of L. + +Params: + +=over + +=item C + +The name of the class that C was called on. You can check this +like C<< if ($class->isa('Some::Class')) >> in your code, to perform specific +tasks before C for only certain classes. + +=item C + +A hashref. The set of named parameters passed to C. + +=back + + +=head2 object_before_delete + +This happens in L, after we've confirmed +that the object can be deleted, but before any rows have actually +been removed from the database. This sometimes occurs inside a database +transaction. + +Params: + +=over + +=item C - The L being deleted. You will probably +want to check its type like C<< $object->isa('Some::Class') >> before doing +anything with it. + +=back + + +=head2 object_before_set + +Called during L, before any actual work is done. +You can use this to perform actions before a value is changed for +specific fields on certain types of objects. + +Params: + +=over + +=item C + +The object that C was called on. You will probably want to +do something like C<< if ($object->isa('Some::Class')) >> in your code to +limit your changes to only certain subclasses of Bugzilla::Object. + +=item C + +The name of the field being updated in the object. + +=item C + +The value being set on the object. + +=back + +=head2 object_end_of_create_validators + +Called at the end of L. You can +use this to run additional validation when creating an object. + +If a subclass has overridden C, then this usually +happens I the subclass does its custom validation. + +Params: + +=over + +=item C + +The name of the class that C was called on. You can check this +like C<< if ($class->isa('Some::Class')) >> in your code, to perform specific +tasks for only certain classes. + +=item C + +A hashref. The set of named parameters passed to C, modified and +validated by the C specified for the object. + +=back + + +=head2 object_end_of_set + +Called during L, after all the code of the function +has completed (so the value has been validated and the field has been set +to the new value). You can use this to perform actions after a value is +changed for specific fields on certain types of objects. + +The new value is not specifically passed to this hook because you can +get it as C<< $object->{$field} >>. + +Params: + +=over + +=item C + +The object that C was called on. You will probably want to +do something like C<< if ($object->isa('Some::Class')) >> in your code to +limit your changes to only certain subclasses of Bugzilla::Object. + +=item C + +The name of the field that was updated in the object. + +=back + + +=head2 object_end_of_set_all + +This happens at the end of L. This is a +good place to call custom set_ functions on objects, or to make changes +to an object before C is called. + +Params: + +=over + +=item C + +The L which is being updated. You will probably want to +do something like C<< if ($object->isa('Some::Class')) >> in your code to +limit your changes to only certain subclasses of Bugzilla::Object. + +=item C + +A hashref. The set of named parameters passed to C. + +=back + +=head2 object_end_of_update + +Called during L, after changes are made +to the database, but while still inside a transaction. + +Params: + +=over + +=item C + +The object that C was called on. You will probably want to +do something like C<< if ($object->isa('Some::Class')) >> in your code to +limit your changes to only certain subclasses of Bugzilla::Object. + +=item C + +The object as it was before it was updated. + +=item C + +The fields that have been changed, in the same format that +L returns. + +=back + +=head2 page_before_template This is a simple way to add your own pages to Bugzilla. This hooks C, which loads templates from F