summaryrefslogtreecommitdiffstats
path: root/library
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-13 11:46:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-13 11:46:43 +0000
commit3e02d5aff85babc3ffbfcf52313f2108e313aa23 (patch)
treeb01f3923360c20a6a504aff42d45670c58af3ec5 /library
parentInitial commit. (diff)
downloadicingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.tar.xz
icingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.zip
Adding upstream version 2.12.1.upstream/2.12.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library')
-rw-r--r--library/Icinga/Application/ApplicationBootstrap.php747
-rw-r--r--library/Icinga/Application/Benchmark.php300
-rw-r--r--library/Icinga/Application/ClassLoader.php306
-rw-r--r--library/Icinga/Application/Cli.php211
-rw-r--r--library/Icinga/Application/Config.php498
-rw-r--r--library/Icinga/Application/EmbeddedWeb.php115
-rw-r--r--library/Icinga/Application/Hook.php328
-rw-r--r--library/Icinga/Application/Hook/ApplicationStateHook.php90
-rw-r--r--library/Icinga/Application/Hook/AuditHook.php123
-rw-r--r--library/Icinga/Application/Hook/AuthenticationHook.php75
-rw-r--r--library/Icinga/Application/Hook/Common/DbMigrationStep.php129
-rw-r--r--library/Icinga/Application/Hook/ConfigFormEventsHook.php137
-rw-r--r--library/Icinga/Application/Hook/DbMigrationHook.php421
-rw-r--r--library/Icinga/Application/Hook/GrapherHook.php111
-rw-r--r--library/Icinga/Application/Hook/HealthHook.php222
-rw-r--r--library/Icinga/Application/Hook/PdfexportHook.php25
-rw-r--r--library/Icinga/Application/Hook/ThemeLoaderHook.php22
-rw-r--r--library/Icinga/Application/Hook/Ticket/TicketPattern.php140
-rw-r--r--library/Icinga/Application/Hook/TicketHook.php210
-rw-r--r--library/Icinga/Application/Hook/WebBaseHook.php54
-rw-r--r--library/Icinga/Application/Icinga.php49
-rw-r--r--library/Icinga/Application/LegacyWeb.php33
-rw-r--r--library/Icinga/Application/Libraries.php91
-rw-r--r--library/Icinga/Application/Libraries/Library.php259
-rw-r--r--library/Icinga/Application/Logger.php349
-rw-r--r--library/Icinga/Application/Logger/LogWriter.php30
-rw-r--r--library/Icinga/Application/Logger/Writer/FileWriter.php80
-rw-r--r--library/Icinga/Application/Logger/Writer/PhpWriter.php39
-rw-r--r--library/Icinga/Application/Logger/Writer/StderrWriter.php62
-rw-r--r--library/Icinga/Application/Logger/Writer/StdoutWriter.php13
-rw-r--r--library/Icinga/Application/Logger/Writer/SyslogWriter.php90
-rw-r--r--library/Icinga/Application/MigrationManager.php417
-rw-r--r--library/Icinga/Application/Modules/DashboardContainer.php58
-rw-r--r--library/Icinga/Application/Modules/Manager.php698
-rw-r--r--library/Icinga/Application/Modules/MenuItemContainer.php55
-rw-r--r--library/Icinga/Application/Modules/Module.php1451
-rw-r--r--library/Icinga/Application/Modules/NavigationItemContainer.php117
-rw-r--r--library/Icinga/Application/Platform.php435
-rw-r--r--library/Icinga/Application/ProvidedHook/DbMigration.php83
-rw-r--r--library/Icinga/Application/StaticWeb.php21
-rw-r--r--library/Icinga/Application/Test.php140
-rw-r--r--library/Icinga/Application/Version.php65
-rw-r--r--library/Icinga/Application/Web.php509
-rw-r--r--library/Icinga/Application/functions.php110
-rw-r--r--library/Icinga/Application/webrouter.php106
-rw-r--r--library/Icinga/Authentication/AdmissionLoader.php249
-rw-r--r--library/Icinga/Authentication/Auth.php453
-rw-r--r--library/Icinga/Authentication/AuthChain.php269
-rw-r--r--library/Icinga/Authentication/Authenticatable.php21
-rw-r--r--library/Icinga/Authentication/Role.php334
-rw-r--r--library/Icinga/Authentication/RolesConfig.php43
-rw-r--r--library/Icinga/Authentication/User/DbUserBackend.php256
-rw-r--r--library/Icinga/Authentication/User/DomainAwareInterface.php17
-rw-r--r--library/Icinga/Authentication/User/ExternalBackend.php124
-rw-r--r--library/Icinga/Authentication/User/LdapUserBackend.php479
-rw-r--r--library/Icinga/Authentication/User/UserBackend.php259
-rw-r--r--library/Icinga/Authentication/User/UserBackendInterface.php39
-rw-r--r--library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php325
-rw-r--r--library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php945
-rw-r--r--library/Icinga/Authentication/UserGroup/UserGroupBackend.php189
-rw-r--r--library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php56
-rw-r--r--library/Icinga/Chart/Axis.php485
-rw-r--r--library/Icinga/Chart/Chart.php162
-rw-r--r--library/Icinga/Chart/Donut.php465
-rw-r--r--library/Icinga/Chart/Format.php21
-rw-r--r--library/Icinga/Chart/Graph/BarGraph.php163
-rw-r--r--library/Icinga/Chart/Graph/LineGraph.php202
-rw-r--r--library/Icinga/Chart/Graph/StackedGraph.php88
-rw-r--r--library/Icinga/Chart/Graph/Tooltip.php143
-rw-r--r--library/Icinga/Chart/GridChart.php446
-rw-r--r--library/Icinga/Chart/Inline/Inline.php96
-rw-r--r--library/Icinga/Chart/Inline/PieChart.php41
-rw-r--r--library/Icinga/Chart/Legend.php102
-rw-r--r--library/Icinga/Chart/Palette.php65
-rw-r--r--library/Icinga/Chart/PieChart.php306
-rw-r--r--library/Icinga/Chart/Primitive/Animatable.php43
-rw-r--r--library/Icinga/Chart/Primitive/Animation.php87
-rw-r--r--library/Icinga/Chart/Primitive/Canvas.php140
-rw-r--r--library/Icinga/Chart/Primitive/Circle.php84
-rw-r--r--library/Icinga/Chart/Primitive/Drawable.php22
-rw-r--r--library/Icinga/Chart/Primitive/Line.php103
-rw-r--r--library/Icinga/Chart/Primitive/Path.php187
-rw-r--r--library/Icinga/Chart/Primitive/PieSlice.php307
-rw-r--r--library/Icinga/Chart/Primitive/RawElement.php43
-rw-r--r--library/Icinga/Chart/Primitive/Rect.php119
-rw-r--r--library/Icinga/Chart/Primitive/Styleable.php161
-rw-r--r--library/Icinga/Chart/Primitive/Text.php184
-rw-r--r--library/Icinga/Chart/Render/LayoutBox.php200
-rw-r--r--library/Icinga/Chart/Render/RenderContext.php225
-rw-r--r--library/Icinga/Chart/Render/Rotator.php80
-rw-r--r--library/Icinga/Chart/SVGRenderer.php331
-rw-r--r--library/Icinga/Chart/Unit/AxisUnit.php56
-rw-r--r--library/Icinga/Chart/Unit/CalendarUnit.php167
-rw-r--r--library/Icinga/Chart/Unit/LinearUnit.php227
-rw-r--r--library/Icinga/Chart/Unit/LogarithmicUnit.php263
-rw-r--r--library/Icinga/Chart/Unit/StaticAxis.php130
-rw-r--r--library/Icinga/Cli/AnsiScreen.php122
-rw-r--r--library/Icinga/Cli/Command.php216
-rw-r--r--library/Icinga/Cli/Documentation.php167
-rw-r--r--library/Icinga/Cli/Documentation/CommentParser.php85
-rw-r--r--library/Icinga/Cli/Loader.php501
-rw-r--r--library/Icinga/Cli/Params.php320
-rw-r--r--library/Icinga/Cli/Screen.php106
-rw-r--r--library/Icinga/Common/Database.php56
-rw-r--r--library/Icinga/Common/PdfExport.php105
-rw-r--r--library/Icinga/Crypt/AesCrypt.php337
-rw-r--r--library/Icinga/Data/ConfigObject.php289
-rw-r--r--library/Icinga/Data/ConnectionInterface.php8
-rw-r--r--library/Icinga/Data/DataArray/ArrayDatasource.php292
-rw-r--r--library/Icinga/Data/Db/DbConnection.php655
-rw-r--r--library/Icinga/Data/Db/DbQuery.php565
-rw-r--r--library/Icinga/Data/Extensible.php22
-rw-r--r--library/Icinga/Data/Fetchable.php47
-rw-r--r--library/Icinga/Data/Filter/Filter.php255
-rw-r--r--library/Icinga/Data/Filter/FilterAnd.php42
-rw-r--r--library/Icinga/Data/Filter/FilterChain.php286
-rw-r--r--library/Icinga/Data/Filter/FilterEqual.php16
-rw-r--r--library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php16
-rw-r--r--library/Icinga/Data/Filter/FilterEqualOrLessThan.php26
-rw-r--r--library/Icinga/Data/Filter/FilterException.php15
-rw-r--r--library/Icinga/Data/Filter/FilterExpression.php224
-rw-r--r--library/Icinga/Data/Filter/FilterGreaterThan.php16
-rw-r--r--library/Icinga/Data/Filter/FilterLessThan.php26
-rw-r--r--library/Icinga/Data/Filter/FilterMatch.php8
-rw-r--r--library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php13
-rw-r--r--library/Icinga/Data/Filter/FilterMatchNot.php12
-rw-r--r--library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php13
-rw-r--r--library/Icinga/Data/Filter/FilterNot.php58
-rw-r--r--library/Icinga/Data/Filter/FilterNotEqual.php12
-rw-r--r--library/Icinga/Data/Filter/FilterOr.php39
-rw-r--r--library/Icinga/Data/Filter/FilterParseException.php10
-rw-r--r--library/Icinga/Data/Filter/FilterQueryString.php320
-rw-r--r--library/Icinga/Data/FilterColumns.php21
-rw-r--r--library/Icinga/Data/Filterable.php27
-rw-r--r--library/Icinga/Data/Identifiable.php17
-rw-r--r--library/Icinga/Data/Inspectable.php20
-rw-r--r--library/Icinga/Data/Inspection.php129
-rw-r--r--library/Icinga/Data/Limitable.php48
-rw-r--r--library/Icinga/Data/Paginatable.php10
-rw-r--r--library/Icinga/Data/PivotTable.php396
-rw-r--r--library/Icinga/Data/QueryInterface.php8
-rw-r--r--library/Icinga/Data/Queryable.php20
-rw-r--r--library/Icinga/Data/Reducible.php23
-rw-r--r--library/Icinga/Data/ResourceFactory.php138
-rw-r--r--library/Icinga/Data/Selectable.php17
-rw-r--r--library/Icinga/Data/SimpleQuery.php650
-rw-r--r--library/Icinga/Data/SortRules.php14
-rw-r--r--library/Icinga/Data/Sortable.php49
-rw-r--r--library/Icinga/Data/Tree/SimpleTree.php90
-rw-r--r--library/Icinga/Data/Tree/TreeNode.php109
-rw-r--r--library/Icinga/Data/Tree/TreeNodeIterator.php75
-rw-r--r--library/Icinga/Data/Updatable.php24
-rw-r--r--library/Icinga/Date/DateFormatter.php265
-rw-r--r--library/Icinga/Exception/AlreadyExistsException.php11
-rw-r--r--library/Icinga/Exception/AuthenticationException.php11
-rw-r--r--library/Icinga/Exception/ConfigurationError.php12
-rw-r--r--library/Icinga/Exception/Http/BaseHttpException.php73
-rw-r--r--library/Icinga/Exception/Http/HttpBadRequestException.php12
-rw-r--r--library/Icinga/Exception/Http/HttpException.php25
-rw-r--r--library/Icinga/Exception/Http/HttpExceptionInterface.php21
-rw-r--r--library/Icinga/Exception/Http/HttpMethodNotAllowedException.php36
-rw-r--r--library/Icinga/Exception/Http/HttpNotFoundException.php12
-rw-r--r--library/Icinga/Exception/IcingaException.php114
-rw-r--r--library/Icinga/Exception/InvalidPropertyException.php11
-rw-r--r--library/Icinga/Exception/Json/JsonDecodeException.php11
-rw-r--r--library/Icinga/Exception/Json/JsonEncodeException.php11
-rw-r--r--library/Icinga/Exception/Json/JsonException.php13
-rw-r--r--library/Icinga/Exception/MissingParameterException.php40
-rw-r--r--library/Icinga/Exception/NotFoundError.php8
-rw-r--r--library/Icinga/Exception/NotImplementedError.php12
-rw-r--r--library/Icinga/Exception/NotReadableError.php8
-rw-r--r--library/Icinga/Exception/NotWritableError.php8
-rw-r--r--library/Icinga/Exception/ProgrammingError.php12
-rw-r--r--library/Icinga/Exception/QueryException.php11
-rw-r--r--library/Icinga/Exception/StatementException.php8
-rw-r--r--library/Icinga/Exception/SystemPermissionException.php11
-rw-r--r--library/Icinga/File/Csv.php47
-rw-r--r--library/Icinga/File/Ini/Dom/Comment.php37
-rw-r--r--library/Icinga/File/Ini/Dom/Directive.php166
-rw-r--r--library/Icinga/File/Ini/Dom/Document.php132
-rw-r--r--library/Icinga/File/Ini/Dom/Section.php190
-rw-r--r--library/Icinga/File/Ini/IniParser.php310
-rw-r--r--library/Icinga/File/Ini/IniWriter.php205
-rw-r--r--library/Icinga/File/Pdf.php81
-rw-r--r--library/Icinga/File/Storage/LocalFileStorage.php164
-rw-r--r--library/Icinga/File/Storage/StorageInterface.php94
-rw-r--r--library/Icinga/File/Storage/TemporaryLocalFileStorage.php59
-rw-r--r--library/Icinga/Legacy/DashboardConfig.php137
-rw-r--r--library/Icinga/Less/Call.php77
-rw-r--r--library/Icinga/Less/ColorProp.php109
-rw-r--r--library/Icinga/Less/ColorPropOrVariable.php71
-rw-r--r--library/Icinga/Less/DeferredColorProp.php136
-rw-r--r--library/Icinga/Less/LightMode.php128
-rw-r--r--library/Icinga/Less/LightModeCall.php38
-rw-r--r--library/Icinga/Less/LightModeDefinition.php75
-rw-r--r--library/Icinga/Less/LightModeTrait.php30
-rw-r--r--library/Icinga/Less/LightModeVisitor.php26
-rw-r--r--library/Icinga/Less/Visitor.php233
-rw-r--r--library/Icinga/Model/Schema.php49
-rw-r--r--library/Icinga/Protocol/Dns.php89
-rw-r--r--library/Icinga/Protocol/File/Exception/FileReaderException.php12
-rw-r--r--library/Icinga/Protocol/File/FileIterator.php81
-rw-r--r--library/Icinga/Protocol/File/FileQuery.php86
-rw-r--r--library/Icinga/Protocol/File/FileReader.php208
-rw-r--r--library/Icinga/Protocol/File/LogFileIterator.php149
-rw-r--r--library/Icinga/Protocol/Ldap/Discovery.php143
-rw-r--r--library/Icinga/Protocol/Ldap/LdapCapabilities.php440
-rw-r--r--library/Icinga/Protocol/Ldap/LdapConnection.php1584
-rw-r--r--library/Icinga/Protocol/Ldap/LdapException.php14
-rw-r--r--library/Icinga/Protocol/Ldap/LdapQuery.php361
-rw-r--r--library/Icinga/Protocol/Ldap/LdapUtils.php148
-rw-r--r--library/Icinga/Protocol/Ldap/Node.php69
-rw-r--r--library/Icinga/Protocol/Ldap/Root.php242
-rw-r--r--library/Icinga/Protocol/Nrpe/Connection.php111
-rw-r--r--library/Icinga/Protocol/Nrpe/Packet.php69
-rw-r--r--library/Icinga/Repository/DbRepository.php1078
-rw-r--r--library/Icinga/Repository/IniRepository.php418
-rw-r--r--library/Icinga/Repository/LdapRepository.php71
-rw-r--r--library/Icinga/Repository/Repository.php1261
-rw-r--r--library/Icinga/Repository/RepositoryQuery.php797
-rw-r--r--library/Icinga/Security/SecurityException.php13
-rw-r--r--library/Icinga/Test/BaseTestCase.php313
-rw-r--r--library/Icinga/Test/ClassLoader.php113
-rw-r--r--library/Icinga/Test/DbTest.php47
-rw-r--r--library/Icinga/User.php649
-rw-r--r--library/Icinga/User/Preferences.php169
-rw-r--r--library/Icinga/User/Preferences/PreferencesStore.php344
-rw-r--r--library/Icinga/Util/ASN1.php102
-rw-r--r--library/Icinga/Util/Color.php121
-rw-r--r--library/Icinga/Util/ConfigAwareFactory.php18
-rw-r--r--library/Icinga/Util/Csp.php107
-rw-r--r--library/Icinga/Util/Dimension.php123
-rw-r--r--library/Icinga/Util/DirectoryIterator.php214
-rw-r--r--library/Icinga/Util/EnumeratingFilterIterator.php30
-rw-r--r--library/Icinga/Util/Environment.php42
-rw-r--r--library/Icinga/Util/File.php195
-rw-r--r--library/Icinga/Util/Format.php197
-rw-r--r--library/Icinga/Util/GlobFilter.php182
-rw-r--r--library/Icinga/Util/Json.php151
-rw-r--r--library/Icinga/Util/LessParser.php15
-rw-r--r--library/Icinga/Util/StringHelper.php184
-rw-r--r--library/Icinga/Util/TimezoneDetect.php107
-rw-r--r--library/Icinga/Web/Announcement.php158
-rw-r--r--library/Icinga/Web/Announcement/AnnouncementCookie.php138
-rw-r--r--library/Icinga/Web/Announcement/AnnouncementIniRepository.php152
-rw-r--r--library/Icinga/Web/ApplicationStateCookie.php74
-rw-r--r--library/Icinga/Web/Controller.php264
-rw-r--r--library/Icinga/Web/Controller/ActionController.php617
-rw-r--r--library/Icinga/Web/Controller/AuthBackendController.php151
-rw-r--r--library/Icinga/Web/Controller/BasePreferenceController.php39
-rw-r--r--library/Icinga/Web/Controller/ControllerTabCollector.php97
-rw-r--r--library/Icinga/Web/Controller/Dispatcher.php93
-rw-r--r--library/Icinga/Web/Controller/ModuleActionController.php80
-rw-r--r--library/Icinga/Web/Controller/StaticController.php87
-rw-r--r--library/Icinga/Web/Cookie.php299
-rw-r--r--library/Icinga/Web/CookieSet.php58
-rw-r--r--library/Icinga/Web/Dom/DomNodeIterator.php84
-rw-r--r--library/Icinga/Web/FileCache.php293
-rw-r--r--library/Icinga/Web/Form.php1666
-rw-r--r--library/Icinga/Web/Form/Decorator/Autosubmit.php133
-rw-r--r--library/Icinga/Web/Form/Decorator/ConditionalHidden.php35
-rw-r--r--library/Icinga/Web/Form/Decorator/ElementDoubler.php63
-rw-r--r--library/Icinga/Web/Form/Decorator/FormDescriptions.php76
-rw-r--r--library/Icinga/Web/Form/Decorator/FormHints.php142
-rw-r--r--library/Icinga/Web/Form/Decorator/FormNotifications.php125
-rw-r--r--library/Icinga/Web/Form/Decorator/Help.php113
-rw-r--r--library/Icinga/Web/Form/Decorator/Spinner.php48
-rw-r--r--library/Icinga/Web/Form/Element/Button.php81
-rw-r--r--library/Icinga/Web/Form/Element/Checkbox.php9
-rw-r--r--library/Icinga/Web/Form/Element/CsrfCounterMeasure.php99
-rw-r--r--library/Icinga/Web/Form/Element/Date.php19
-rw-r--r--library/Icinga/Web/Form/Element/DateTimePicker.php80
-rw-r--r--library/Icinga/Web/Form/Element/Note.php55
-rw-r--r--library/Icinga/Web/Form/Element/Number.php144
-rw-r--r--library/Icinga/Web/Form/Element/Textarea.php20
-rw-r--r--library/Icinga/Web/Form/Element/Time.php19
-rw-r--r--library/Icinga/Web/Form/ErrorLabeller.php71
-rw-r--r--library/Icinga/Web/Form/FormElement.php61
-rw-r--r--library/Icinga/Web/Form/InvalidCSRFTokenException.php11
-rw-r--r--library/Icinga/Web/Form/Validator/DateFormatValidator.php61
-rw-r--r--library/Icinga/Web/Form/Validator/DateTimeValidator.php77
-rw-r--r--library/Icinga/Web/Form/Validator/InArray.php28
-rw-r--r--library/Icinga/Web/Form/Validator/InternalUrlValidator.php41
-rw-r--r--library/Icinga/Web/Form/Validator/ReadablePathValidator.php53
-rw-r--r--library/Icinga/Web/Form/Validator/TimeFormatValidator.php58
-rw-r--r--library/Icinga/Web/Form/Validator/UrlValidator.php40
-rw-r--r--library/Icinga/Web/Form/Validator/WritablePathValidator.php72
-rw-r--r--library/Icinga/Web/Helper/CookieHelper.php81
-rw-r--r--library/Icinga/Web/Helper/HtmlPurifier.php95
-rw-r--r--library/Icinga/Web/Helper/Markdown.php34
-rw-r--r--library/Icinga/Web/Helper/Markdown/LinkTransformer.php73
-rw-r--r--library/Icinga/Web/Hook.php16
-rw-r--r--library/Icinga/Web/JavaScript.php269
-rw-r--r--library/Icinga/Web/LessCompiler.php255
-rw-r--r--library/Icinga/Web/Menu.php152
-rw-r--r--library/Icinga/Web/Navigation/ConfigMenu.php327
-rw-r--r--library/Icinga/Web/Navigation/DashboardPane.php84
-rw-r--r--library/Icinga/Web/Navigation/DropdownItem.php20
-rw-r--r--library/Icinga/Web/Navigation/Navigation.php572
-rw-r--r--library/Icinga/Web/Navigation/NavigationItem.php948
-rw-r--r--library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php139
-rw-r--r--library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php44
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php235
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php356
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php142
-rw-r--r--library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php186
-rw-r--r--library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php72
-rw-r--r--library/Icinga/Web/Notification.php220
-rw-r--r--library/Icinga/Web/Paginator/Adapter/QueryAdapter.php84
-rw-r--r--library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php78
-rw-r--r--library/Icinga/Web/RememberMe.php363
-rw-r--r--library/Icinga/Web/RememberMeUserDevicesList.php144
-rw-r--r--library/Icinga/Web/RememberMeUserList.php106
-rw-r--r--library/Icinga/Web/Request.php142
-rw-r--r--library/Icinga/Web/Response.php460
-rw-r--r--library/Icinga/Web/Response/JsonResponse.php241
-rw-r--r--library/Icinga/Web/Session.php54
-rw-r--r--library/Icinga/Web/Session/Php72Session.php37
-rw-r--r--library/Icinga/Web/Session/PhpSession.php256
-rw-r--r--library/Icinga/Web/Session/Session.php126
-rw-r--r--library/Icinga/Web/Session/SessionNamespace.php201
-rw-r--r--library/Icinga/Web/StyleSheet.php342
-rw-r--r--library/Icinga/Web/Url.php806
-rw-r--r--library/Icinga/Web/UrlParams.php433
-rw-r--r--library/Icinga/Web/UserAgent.php86
-rw-r--r--library/Icinga/Web/View.php254
-rw-r--r--library/Icinga/Web/View/AppHealth.php89
-rw-r--r--library/Icinga/Web/View/Helper/IcingaCheckbox.php30
-rw-r--r--library/Icinga/Web/View/PrivilegeAudit.php622
-rw-r--r--library/Icinga/Web/View/helpers/format.php72
-rw-r--r--library/Icinga/Web/View/helpers/generic.php15
-rw-r--r--library/Icinga/Web/View/helpers/string.php36
-rw-r--r--library/Icinga/Web/View/helpers/url.php158
-rw-r--r--library/Icinga/Web/Widget.php49
-rw-r--r--library/Icinga/Web/Widget/AbstractWidget.php121
-rw-r--r--library/Icinga/Web/Widget/Announcements.php55
-rw-r--r--library/Icinga/Web/Widget/ApplicationStateMessages.php74
-rw-r--r--library/Icinga/Web/Widget/Chart/HistoryColorGrid.php400
-rw-r--r--library/Icinga/Web/Widget/Chart/InlinePie.php257
-rw-r--r--library/Icinga/Web/Widget/Dashboard.php475
-rw-r--r--library/Icinga/Web/Widget/Dashboard/Dashlet.php315
-rw-r--r--library/Icinga/Web/Widget/Dashboard/Pane.php335
-rw-r--r--library/Icinga/Web/Widget/Dashboard/UserWidget.php36
-rw-r--r--library/Icinga/Web/Widget/FilterEditor.php811
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php92
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationList.php133
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationListItem.php151
-rw-r--r--library/Icinga/Web/Widget/Limiter.php54
-rw-r--r--library/Icinga/Web/Widget/Paginator.php167
-rw-r--r--library/Icinga/Web/Widget/SearchDashboard.php111
-rw-r--r--library/Icinga/Web/Widget/SingleValueSearchControl.php200
-rw-r--r--library/Icinga/Web/Widget/SortBox.php260
-rw-r--r--library/Icinga/Web/Widget/Tab.php323
-rw-r--r--library/Icinga/Web/Widget/Tabextension/DashboardAction.php35
-rw-r--r--library/Icinga/Web/Widget/Tabextension/DashboardSettings.php39
-rw-r--r--library/Icinga/Web/Widget/Tabextension/MenuAction.php35
-rw-r--r--library/Icinga/Web/Widget/Tabextension/OutputFormat.php114
-rw-r--r--library/Icinga/Web/Widget/Tabextension/Tabextension.php25
-rw-r--r--library/Icinga/Web/Widget/Tabs.php453
-rw-r--r--library/Icinga/Web/Widget/Widget.php24
-rw-r--r--library/Icinga/Web/Window.php125
-rw-r--r--library/Icinga/Web/Wizard.php720
362 files changed, 62756 insertions, 0 deletions
diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php
new file mode 100644
index 0000000..e484f6c
--- /dev/null
+++ b/library/Icinga/Application/ApplicationBootstrap.php
@@ -0,0 +1,747 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use DirectoryIterator;
+use ErrorException;
+use Exception;
+use Icinga\Application\ProvidedHook\DbMigration;
+use ipl\I18n\GettextTranslator;
+use ipl\I18n\StaticTranslator;
+use LogicException;
+use Icinga\Application\Modules\Manager as ModuleManager;
+use Icinga\Authentication\User\UserBackend;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\IcingaException;
+
+/**
+ * This class bootstraps a thin Icinga application layer
+ *
+ * Usage example for CLI:
+ * <code>
+ * use Icinga\Application\Cli;
+
+ * Cli::start();
+ * </code>
+ *
+ * Usage example for Icinga Web application:
+ * <code>
+ * use Icinga\Application\Web;
+ * Web::start()->dispatch();
+ * </code>
+ *
+ * Usage example for Icinga-Web 1.x compatibility mode:
+ * <code>
+ * use Icinga\Application\LegacyWeb;
+ * LegacyWeb::start()->setIcingaWebBasedir(ICINGAWEB_BASEDIR)->dispatch();
+ * </code>
+ */
+abstract class ApplicationBootstrap
+{
+ /**
+ * Base directory
+ *
+ * Parent folder for at least application, bin, modules and public
+ *
+ * @var string
+ */
+ protected $baseDir;
+
+ /**
+ * Application directory
+ *
+ * @var string
+ */
+ protected $appDir;
+
+ /**
+ * Icinga library directory
+ *
+ * @var string
+ */
+ protected $libDir;
+
+ /**
+ * Configuration directory
+ *
+ * @var string
+ */
+ protected $configDir;
+
+ /**
+ * Locale directory
+ *
+ * @var string
+ */
+ protected $localeDir;
+
+ /**
+ * Common storage directory
+ *
+ * @var string
+ */
+ protected $storageDir;
+
+ /**
+ * External library paths
+ *
+ * @var string[]
+ */
+ protected $libraryPaths;
+
+ /**
+ * Loaded external libraries
+ *
+ * @var Libraries
+ */
+ protected $libraries;
+
+ /**
+ * Icinga class loader
+ *
+ * @var ClassLoader
+ */
+ private $loader;
+
+ /**
+ * Config object
+ *
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * Module manager
+ *
+ * @var ModuleManager
+ */
+ private $moduleManager;
+
+ /**
+ * Flag indicates we're on cli environment
+ *
+ * @var bool
+ */
+ protected $isCli = false;
+
+ /**
+ * Flag indicates we're on web environment
+ *
+ * @var bool
+ */
+ protected $isWeb = false;
+
+ /**
+ * Whether Icinga Web 2 requires setup
+ *
+ * @var bool
+ */
+ protected $requiresSetup = false;
+
+ /**
+ * Constructor
+ *
+ * @param string $baseDir Icinga Web 2 base directory
+ * @param string $configDir Path to Icinga Web 2's configuration files
+ * @param string $storageDir Path to Icinga Web 2's stored files
+ */
+ protected function __construct($baseDir = null, $configDir = null, $storageDir = null)
+ {
+ if ($baseDir === null) {
+ $baseDir = dirname($this->getBootstrapDirectory());
+ }
+ $this->baseDir = $baseDir;
+ $this->appDir = $baseDir . '/application';
+ if (substr(__DIR__, 0, 8) === 'phar:///') {
+ $this->libDir = dirname(dirname(__DIR__));
+ } else {
+ $this->libDir = realpath(__DIR__ . '/../..');
+ }
+
+ $this->setupAutoloader();
+
+ if ($configDir === null) {
+ $configDir = getenv('ICINGAWEB_CONFIGDIR');
+ if ($configDir === false) {
+ $configDir = Platform::isWindows()
+ ? $baseDir . '/config'
+ : '/etc/icingaweb2';
+ }
+ }
+ $canonical = realpath($configDir);
+ $this->configDir = $canonical ? $canonical : $configDir;
+
+ if ($storageDir === null) {
+ $storageDir = getenv('ICINGAWEB_STORAGEDIR');
+ if ($storageDir === false) {
+ $storageDir = Platform::isWindows()
+ ? $baseDir . '/storage'
+ : '/var/lib/icingaweb2';
+ }
+ }
+ $canonical = realpath($storageDir);
+ $this->storageDir = $canonical ? $canonical : $storageDir;
+
+ if ($this->libraryPaths === null) {
+ $libraryPaths = getenv('ICINGAWEB_LIBDIR');
+ if ($libraryPaths !== false) {
+ $this->libraryPaths = array_filter(array_map(
+ 'realpath',
+ explode(':', $libraryPaths)
+ ), 'is_dir');
+ } else {
+ $this->libraryPaths = is_dir('/usr/share/icinga-php')
+ ? ['/usr/share/icinga-php']
+ : [];
+ }
+ }
+
+ Icinga::setApp($this);
+
+ require_once dirname(__FILE__) . '/functions.php';
+ }
+
+ /**
+ * Bootstrap interface method for concrete bootstrap objects
+ *
+ * @return mixed
+ */
+ abstract protected function bootstrap();
+
+ /**
+ * Get loaded external libraries
+ *
+ * @return Libraries
+ */
+ public function getLibraries()
+ {
+ return $this->libraries;
+ }
+
+ /**
+ * Getter for module manager
+ *
+ * @return ModuleManager
+ */
+ public function getModuleManager()
+ {
+ return $this->moduleManager;
+ }
+
+ /**
+ * Getter for class loader
+ *
+ * @return ClassLoader
+ */
+ public function getLoader()
+ {
+ return $this->loader;
+ }
+
+ /**
+ * Getter for configuration object
+ *
+ * @return Config
+ */
+ public function getConfig()
+ {
+ return $this->config;
+ }
+
+ /**
+ * Flag indicates we're on cli environment
+ *
+ * @return bool
+ */
+ public function isCli()
+ {
+ return $this->isCli;
+ }
+
+ /**
+ * Flag indicates we're on web environment
+ *
+ * @return bool
+ */
+ public function isWeb()
+ {
+ return $this->isWeb;
+ }
+
+ /**
+ * Helper to glue directories together
+ *
+ * @param string $dir
+ * @param string $subdir
+ *
+ * @return string
+ */
+ private function getDirWithSubDir($dir, $subdir = null)
+ {
+ if ($subdir !== null) {
+ $dir .= '/' . ltrim($subdir, '/');
+ }
+
+ return $dir;
+ }
+
+ /**
+ * Get the base directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getBaseDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->baseDir, $subDir);
+ }
+
+ /**
+ * Get the application directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getApplicationDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->appDir, $subDir);
+ }
+
+ /**
+ * Get the configuration directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getConfigDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->configDir, $subDir);
+ }
+
+ /**
+ * Get the common storage directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getStorageDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->storageDir, $subDir);
+ }
+
+ /**
+ * Get the Icinga library directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getLibraryDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->libDir, $subDir);
+ }
+
+ /**
+ * Get the path to the bootstrapping directory
+ *
+ * This is usually /public for Web and EmbeddedWeb and /bin for the CLI
+ *
+ * @return string
+ *
+ * @throws LogicException If the base directory can not be detected
+ */
+ public function getBootstrapDirectory()
+ {
+ $script = $_SERVER['SCRIPT_FILENAME'];
+ $canonical = realpath($script);
+ if ($canonical !== false) {
+ $dir = dirname($canonical);
+ } elseif (substr($script, -14) === '/webrouter.php') {
+ // If Icinga Web 2 is served using PHP's built-in webserver with our webrouter.php script, the $_SERVER
+ // variable SCRIPT_FILENAME is set to DOCUMENT_ROOT/webrouter.php which is not a valid path to
+ // realpath but DOCUMENT_ROOT here still is the bootstrapping directory
+ $dir = dirname($script);
+ } else {
+ throw new LogicException('Can\'t detected base directory');
+ }
+ return $dir;
+ }
+
+ /**
+ * Start the bootstrap
+ *
+ * @param string $baseDir Icinga Web 2 base directory
+ * @param string $configDir Path to Icinga Web 2's configuration files
+ *
+ * @return static
+ */
+ public static function start($baseDir = null, $configDir = null)
+ {
+ $application = new static($baseDir, $configDir);
+ $application->bootstrap();
+ return $application;
+ }
+
+ /**
+ * Setup Icinga class loader
+ *
+ * @return $this
+ */
+ public function setupAutoloader()
+ {
+ require_once $this->libDir . '/Icinga/Application/ClassLoader.php';
+
+ $this->loader = new ClassLoader();
+ $this->loader->registerNamespace('Icinga', $this->libDir . '/Icinga');
+ $this->loader->registerNamespace('Icinga', $this->libDir . '/Icinga', $this->appDir);
+ $this->loader->register();
+
+ return $this;
+ }
+
+ /**
+ * Setup module manager
+ *
+ * @return $this
+ */
+ protected function setupModuleManager()
+ {
+ $paths = $this->getAvailableModulePaths();
+ $this->moduleManager = new ModuleManager(
+ $this,
+ $this->configDir . '/enabledModules',
+ $paths
+ );
+ return $this;
+ }
+
+ protected function getAvailableModulePaths()
+ {
+ $paths = [];
+
+ $configured = getenv('ICINGAWEB_MODULES_DIR');
+ if (! $configured) {
+ $configured = $this->config->get('global', 'module_path', $this->baseDir . '/modules');
+ }
+
+ $nextIsPhar = false;
+ foreach (explode(PATH_SEPARATOR, $configured) as $path) {
+ if ($path === 'phar') {
+ $nextIsPhar = true;
+ continue;
+ }
+
+ if ($nextIsPhar) {
+ $nextIsPhar = false;
+ $paths[] = 'phar:' . $path;
+ } else {
+ $paths[] = $path;
+ }
+ }
+
+ return $paths;
+ }
+
+ /**
+ * Load all enabled modules
+ *
+ * @return $this
+ */
+ protected function loadEnabledModules()
+ {
+ try {
+ $this->moduleManager->loadEnabledModules();
+ } catch (NotReadableError $e) {
+ Logger::error(new IcingaException('Cannot load enabled modules. An exception was thrown:', $e));
+ }
+ return $this;
+ }
+
+ /**
+ * Load the setup module if Icinga Web 2 requires setup or the setup token exists
+ *
+ * @return $this
+ */
+ protected function loadSetupModuleIfNecessary()
+ {
+ if (! @file_exists($this->config->resolvePath('authentication.ini'))) {
+ $this->requiresSetup = true;
+ if ($this->moduleManager->hasInstalled('setup')) {
+ $this->moduleManager->loadModule('setup');
+ }
+ } elseif ($this->setupTokenExists()) {
+ // Load setup module but do not require setup
+ if ($this->moduleManager->hasInstalled('setup')) {
+ $this->moduleManager->loadModule('setup');
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Get whether Icinga Web 2 requires setup
+ *
+ * @return bool
+ */
+ public function requiresSetup()
+ {
+ return $this->requiresSetup;
+ }
+
+ /**
+ * Get whether the setup token exists
+ *
+ * @return bool
+ */
+ public function setupTokenExists()
+ {
+ return @file_exists($this->config->resolvePath('setup.token'));
+ }
+
+ /**
+ * Load external libraries
+ *
+ * @return $this
+ */
+ protected function loadLibraries()
+ {
+ $this->libraries = new Libraries();
+ foreach ($this->libraryPaths as $libraryPath) {
+ foreach (new DirectoryIterator($libraryPath) as $path) {
+ if (! $path->isDot() && is_dir($path->getRealPath())) {
+ $this->libraries->registerPath($path->getPathname())
+ ->registerAutoloader();
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Setup default logging
+ *
+ * @return $this
+ */
+ protected function setupLogging()
+ {
+ Logger::create(
+ new ConfigObject(
+ array(
+ 'log' => 'syslog'
+ )
+ )
+ );
+ return $this;
+ }
+
+ /**
+ * Load Configuration
+ *
+ * @return $this
+ */
+ protected function loadConfig()
+ {
+ Config::$configDir = $this->configDir;
+
+ try {
+ $this->config = Config::app();
+ } catch (NotReadableError $e) {
+ Logger::error(new IcingaException('Cannot load application configuration. An exception was thrown:', $e));
+ $this->config = new Config();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Error handling configuration
+ *
+ * @return $this
+ */
+ protected function setupErrorHandling()
+ {
+ error_reporting(E_ALL | E_STRICT);
+ ini_set('display_startup_errors', 1);
+ ini_set('display_errors', 1);
+ set_error_handler(function ($errno, $errstr, $errfile, $errline) {
+ if (! (error_reporting() & $errno)) {
+ // Error was suppressed with the @-operator
+ return false; // Continue with the normal error handler
+ }
+ switch ($errno) {
+ case E_NOTICE:
+ case E_WARNING:
+ case E_STRICT:
+ case E_RECOVERABLE_ERROR:
+ throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
+ }
+ return false; // Continue with the normal error handler
+ });
+ return $this;
+ }
+
+ /**
+ * Set up logger
+ *
+ * @return $this
+ */
+ protected function setupLogger()
+ {
+ if ($this->config->hasSection('logging')) {
+ $loggingConfig = $this->config->getSection('logging');
+
+ try {
+ Logger::create($loggingConfig);
+ } catch (ConfigurationError $e) {
+ Logger::getInstance()->registerConfigError($e->getMessage());
+
+ try {
+ Logger::getInstance()->setLevel($loggingConfig->get('level', Logger::ERROR));
+ } catch (ConfigurationError $e) {
+ Logger::getInstance()->registerConfigError($e->getMessage());
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set up the user backend factory
+ *
+ * @return $this
+ */
+ protected function setupUserBackendFactory()
+ {
+ try {
+ UserBackend::setConfig(Config::app('authentication'));
+ } catch (NotReadableError $e) {
+ Logger::error(
+ new IcingaException('Cannot load user backend configuration. An exception was thrown:', $e)
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Detect the timezone
+ *
+ * @return null|string
+ */
+ protected function detectTimezone()
+ {
+ return null;
+ }
+
+ /**
+ * Set up the timezone
+ *
+ * @return $this
+ */
+ final protected function setupTimezone()
+ {
+ $timezone = $this->detectTimezone();
+ if ($timezone === null || @date_default_timezone_set($timezone) === false) {
+ date_default_timezone_set(@date_default_timezone_get());
+ }
+ return $this;
+ }
+
+ /**
+ * Detect the locale
+ *
+ * @return null|string
+ */
+ protected function detectLocale()
+ {
+ return null;
+ }
+
+ /**
+ * Prepare internationalization using gettext
+ *
+ * @return $this
+ */
+ protected function prepareInternationalization()
+ {
+ StaticTranslator::$instance = (new GettextTranslator())
+ ->setDefaultDomain('icinga');
+
+ return $this;
+ }
+
+ /**
+ * Set up internationalization using gettext
+ *
+ * @return $this
+ */
+ final protected function setupInternationalization()
+ {
+ /** @var GettextTranslator $translator */
+ $translator = StaticTranslator::$instance;
+
+ if ($this->hasLocales()) {
+ $translator->addTranslationDirectory($this->getLocaleDir(), 'icinga');
+ }
+
+ $locale = $this->detectLocale();
+ if ($locale === null) {
+ $locale = $translator->getDefaultLocale();
+ }
+
+ try {
+ $translator->setLocale($locale);
+ } catch (Exception $error) {
+ Logger::error($error);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return string Our locale directory
+ */
+ public function getLocaleDir()
+ {
+ if ($this->localeDir === null) {
+ $L10nLocales = getenv('ICINGAWEB_LOCALEDIR') ?: '/usr/share/icinga-L10n/locale';
+ if (file_exists($L10nLocales) && is_dir($L10nLocales)) {
+ $this->localeDir = $L10nLocales;
+ } else {
+ $this->localeDir = false;
+ }
+ }
+
+ return $this->localeDir;
+ }
+
+ /**
+ * return bool Whether Icinga Web has translations
+ */
+ public function hasLocales()
+ {
+ $localedir = $this->getLocaleDir();
+ return $localedir !== false && file_exists($localedir) && is_dir($localedir);
+ }
+
+ /**
+ * Register all hooks provided by the main application
+ *
+ * @return $this
+ */
+ protected function registerApplicationHooks(): self
+ {
+ Hook::register('DbMigration', DbMigration::class, DbMigration::class);
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Benchmark.php b/library/Icinga/Application/Benchmark.php
new file mode 100644
index 0000000..32a05ec
--- /dev/null
+++ b/library/Icinga/Application/Benchmark.php
@@ -0,0 +1,300 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+/**
+ * Icinga\Application\Benchmark class
+ */
+namespace Icinga\Application;
+
+use Icinga\Util\Format;
+
+/**
+ * This class provides a simple and lightweight benchmark class
+ *
+ * <code>
+ * Benchmark::measure('Program started');
+ * // ...do something...
+ * Benchmark::measure('Task finieshed');
+ * Benchmark::dump();
+ * </code>
+ */
+class Benchmark
+{
+ const TIME = 0x01;
+ const MEMORY = 0x02;
+
+ protected static $instance;
+ protected $start;
+ protected $measures = array();
+
+ /**
+ * Add a measurement to your benchmark
+ *
+ * The same identifier can also be used multiple times
+ *
+ * @param string A comment identifying the current measurement
+ * @return void
+ */
+ public static function measure($message)
+ {
+ self::getInstance()->measures[] = (object) array(
+ 'timestamp' => microtime(true),
+ 'memory_real' => memory_get_usage(true),
+ 'memory' => memory_get_usage(),
+ 'message' => $message
+ );
+ }
+
+ /**
+ * Throws all measurements away
+ *
+ * This empties your measurement table and allows you to restart your
+ * benchmark from scratch
+ *
+ * @return void
+ */
+ public static function reset()
+ {
+ self::$instance = null;
+ }
+
+ /**
+ * Rerieve benchmark start time
+ *
+ * This will give you the timestamp of your first measurement
+ *
+ * @return float
+ */
+ public static function getStartTime()
+ {
+ return self::getInstance()->start;
+ }
+
+ /**
+ * Dump benchmark data
+ *
+ * Will dump a text table if running on CLI and a simple HTML table
+ * otherwise. Use Benchmark::TIME and Benchmark::MEMORY to choose whether
+ * you prefer to show either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ */
+ public static function dump($what = null)
+ {
+ if (Icinga::app()->isCli()) {
+ echo self::renderToText($what);
+ } else {
+ echo self::renderToHtml($what);
+ }
+ }
+
+ /**
+ * Render benchmark data to a simple text table
+ *
+ * Use Benchmark::TIME and Icinga::MEMORY to choose whether you prefer to
+ * show either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ * @return string
+ */
+ public static function renderToText($what = null)
+ {
+ $data = self::prepareDataForRendering($what);
+ $sep = '+';
+ $title = '|';
+ foreach ($data->columns as & $col) {
+ $col->format = ' %'
+ . ($col->align === 'right' ? '' : '-')
+ . $col->maxlen . 's |';
+
+ $sep .= str_repeat('-', $col->maxlen) . '--+';
+ $title .= sprintf($col->format, $col->title);
+ }
+
+ $out = $sep . "\n" . $title . "\n" . $sep . "\n";
+ foreach ($data->rows as & $row) {
+ $r = '|';
+ foreach ($data->columns as $key => & $col) {
+ $r .= sprintf($col->format, $row[$key]);
+ }
+ $out .= $r . "\n";
+ }
+
+ $out .= $sep . "\n";
+ return $out;
+ }
+
+ /**
+ * Render benchmark data to a simple HTML table
+ *
+ * Use Benchmark::TIME and Benchmark::MEMORY to choose whether you prefer
+ * to show either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ *
+ * @return string
+ */
+ public static function renderToHtml($what = null)
+ {
+ $data = self::prepareDataForRendering($what);
+
+ // TODO: Move formatting to CSS file
+ $html = '<table class="benchmark">' . "\n" . '<tr>';
+ foreach ($data->columns as & $col) {
+ if ($col->title === 'Time') {
+ continue;
+ }
+ $html .= sprintf(
+ '<td align="%s">%s</td>',
+ $col->align,
+ htmlspecialchars($col->title)
+ );
+ }
+ $html .= "</tr>\n";
+
+ foreach ($data->rows as & $row) {
+ $html .= '<tr>';
+ foreach ($data->columns as $key => & $col) {
+ if ($col->title === 'Time') {
+ continue;
+ }
+ $html .= sprintf(
+ '<td align="%s">%s</td>',
+ $col->align,
+ $row[$key]
+ );
+ }
+ $html .= "</tr>\n";
+ }
+ $html .= "</table>\n";
+ return $html;
+ }
+
+ /**
+ * Prepares benchmark data for output
+ *
+ * Use Benchmark::TIME and Benchmark::MEMORY to choose whether you prefer
+ * to have either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ *
+ * @return object
+ */
+ protected static function prepareDataForRendering($what = null)
+ {
+ if ($what === null) {
+ $what = self::TIME | self::MEMORY;
+ }
+
+ $columns = array(
+ (object) array(
+ 'title' => 'Time',
+ 'align' => 'left',
+ 'maxlen' => 4
+ ),
+ (object) array(
+ 'title' => 'Description',
+ 'align' => 'left',
+ 'maxlen' => 11
+ )
+ );
+ if ($what & self::TIME) {
+ $columns[] = (object) array(
+ 'title' => 'Off (ms)',
+ 'align' => 'right',
+ 'maxlen' => 11
+ );
+ $columns[] = (object) array(
+ 'title' => 'Dur (ms)',
+ 'align' => 'right',
+ 'maxlen' => 13
+ );
+ }
+ if ($what & self::MEMORY) {
+ $columns[] = (object) array(
+ 'title' => 'Mem (diff)',
+ 'align' => 'right',
+ 'maxlen' => 10
+ );
+ $columns[] = (object) array(
+ 'title' => 'Mem (total)',
+ 'align' => 'right',
+ 'maxlen' => 11
+ );
+ }
+
+ $bench = self::getInstance();
+ $last = $bench->start;
+ $rows = array();
+ $lastmem = 0;
+ foreach ($bench->measures as $m) {
+ $micro = sprintf(
+ '%03d',
+ round(($m->timestamp - floor($m->timestamp)) * 1000)
+ );
+ $vals = array(
+ date('H:i:s', (int) $m->timestamp) . '.' . $micro,
+ $m->message
+ );
+
+ if ($what & self::TIME) {
+ $m->relative = $m->timestamp - $bench->start;
+ $m->offset = $m->timestamp - $last;
+ $last = $m->timestamp;
+ $vals[] = sprintf('%0.3f', $m->relative * 1000);
+ $vals[] = sprintf('%0.3f', $m->offset * 1000);
+ }
+
+ if ($what & self::MEMORY) {
+ $mem = $m->memory - $lastmem;
+ $lastmem = $m->memory;
+ $vals[] = Format::bytes($mem);
+ $vals[] = Format::bytes($m->memory);
+ }
+
+ $row = & $rows[];
+ foreach ($vals as $col => $val) {
+ $row[$col] = $val;
+ $columns[$col]->maxlen = max(
+ strlen($val),
+ $columns[$col]->maxlen
+ );
+ }
+ }
+
+ return (object) array(
+ 'columns' => $columns,
+ 'rows' => $rows
+ );
+ }
+
+ /**
+ * Singleton
+ *
+ * Benchmark is run only once, but you are not allowed to directly access
+ * the getInstance() method
+ *
+ * @return self
+ */
+ protected static function getInstance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new Benchmark();
+ self::$instance->start = microtime(true);
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Constructor
+ *
+ * Singleton usage is enforced, the only way to instantiate Benchmark is by
+ * starting your measurements
+ *
+ * @return void
+ */
+ protected function __construct()
+ {
+ }
+}
diff --git a/library/Icinga/Application/ClassLoader.php b/library/Icinga/Application/ClassLoader.php
new file mode 100644
index 0000000..71b4d3e
--- /dev/null
+++ b/library/Icinga/Application/ClassLoader.php
@@ -0,0 +1,306 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+/**
+ * PSR-4 class loader
+ */
+class ClassLoader
+{
+ /**
+ * Namespace separator
+ */
+ const NAMESPACE_SEPARATOR = '\\';
+
+ /**
+ * Icinga Web 2 module namespace prefix
+ */
+ const MODULE_PREFIX = 'Icinga\\Module\\';
+
+ /**
+ * Icinga Web 2 module namespace prefix length
+ *
+ * Helps to make substr/strpos operations even faster
+ */
+ const MODULE_PREFIX_LENGTH = 14;
+
+ /**
+ * A hardcoded class/subdir map for application ns prefixes
+ *
+ * When a module registers with an application directory, those
+ * namespace prefixes (after the module prefix) will be looked up
+ * in the corresponding application subdirectories
+ *
+ * @var array
+ */
+ protected $applicationPrefixes = array(
+ 'Clicommands' => 'clicommands',
+ 'Controllers' => 'controllers',
+ 'Forms' => 'forms'
+ );
+
+ /**
+ * Whether we already instantiated the ZF autoloader
+ *
+ * @var boolean
+ */
+ protected $gotZend = false;
+
+ /**
+ * Namespaces
+ *
+ * @var array
+ */
+ private $namespaces = array();
+
+ /**
+ * Application directories
+ *
+ * @var array
+ */
+ private $applicationDirectories = array();
+
+ /**
+ * Register a base directory for a namespace prefix
+ *
+ * Application directory is optional and provides additional lookup
+ * logic for hardcoded namespaces like "Forms"
+ *
+ * @param string $namespace
+ * @param string $directory
+ * @param string $appDirectory
+ *
+ * @return $this
+ */
+ public function registerNamespace($namespace, $directory, $appDirectory = null)
+ {
+ $this->namespaces[$namespace] = $directory;
+
+ if ($appDirectory !== null) {
+ $this->applicationDirectories[$namespace] = $appDirectory;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Test whether a namespace exists
+ *
+ * @param string $namespace
+ *
+ * @return bool
+ */
+ public function hasNamespace($namespace)
+ {
+ return array_key_exists($namespace, $this->namespaces);
+ }
+
+ /**
+ * Get the source file of the given class or interface
+ *
+ * @param string $class Name of the class or interface
+ *
+ * @return string|null
+ */
+ public function getSourceFile($class)
+ {
+ if ($file = $this->getModuleSourceFile($class)) {
+ return $file;
+ }
+
+ foreach ($this->namespaces as $namespace => $dir) {
+ if ($class === strstr($class, "$namespace\\")) {
+ return $this->buildClassFilename($class, $namespace);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the source file of the given module class or interface
+ *
+ * @param string $class Module class or interface name
+ *
+ * @return string|null
+ */
+ protected function getModuleSourceFile($class)
+ {
+ if (! $this->classBelongsToModule($class)) {
+ return null;
+ }
+
+ $modules = Icinga::app()->getModuleManager();
+ $namespace = $this->extractModuleNamespace($class);
+
+ if ($this->hasNamespace($namespace)) {
+ return $this->buildClassFilename($class, $namespace);
+ } elseif (! $modules->loadedAllEnabledModules()) {
+ $moduleName = $this->extractModuleName($class);
+
+ if ($modules->hasEnabled($moduleName)) {
+ $modules->loadModule($moduleName);
+
+ return $this->buildClassFilename($class, $namespace);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract the Icinga module namespace from a given namespaced class name
+ *
+ * Does no validation, prefix must have been checked before
+ *
+ * @return string
+ */
+ protected function extractModuleNamespace($class)
+ {
+ return substr(
+ $class,
+ 0,
+ strpos($class, self::NAMESPACE_SEPARATOR, self::MODULE_PREFIX_LENGTH + 1)
+ );
+ }
+
+ /**
+ * Extract the Icinga module name from a given namespaced class name
+ *
+ * Does no validation, prefix must have been checked before
+ *
+ * @return string
+ */
+ public static function extractModuleName($class)
+ {
+ return lcfirst(
+ substr(
+ $class,
+ self::MODULE_PREFIX_LENGTH,
+ strpos(
+ $class,
+ self::NAMESPACE_SEPARATOR,
+ self::MODULE_PREFIX_LENGTH + 1
+ ) - self::MODULE_PREFIX_LENGTH
+ )
+ );
+ }
+
+ /**
+ * Whether the given class name belongs to a module namespace
+ *
+ * @return boolean
+ */
+ public static function classBelongsToModule($class)
+ {
+ return substr($class, 0, self::MODULE_PREFIX_LENGTH) === self::MODULE_PREFIX;
+ }
+
+ /**
+ * Prepare a filename string for the given class
+ *
+ * Expects the given namespace to be registered with a path name
+ *
+ * @return string
+ */
+ protected function buildClassFilename($class, $namespace)
+ {
+ $relNs = substr($class, strlen($namespace) + 1);
+
+ if ($this->namespaceHasApplictionDirectory($namespace)) {
+ $prefixSeparator = strpos($relNs, self::NAMESPACE_SEPARATOR);
+ $prefix = substr($relNs, 0, $prefixSeparator);
+
+ if ($this->isApplicationPrefix($prefix)) {
+ return $this->applicationDirectories[$namespace]
+ . DIRECTORY_SEPARATOR
+ . $this->applicationPrefixes[$prefix]
+ . $this->classToRelativePhpFilename(substr($relNs, $prefixSeparator));
+ }
+ }
+
+ return $this->namespaces[$namespace] . DIRECTORY_SEPARATOR . $this->classToRelativePhpFilename($relNs);
+ }
+
+ /**
+ * Return the relative file name for the given (namespaces) class
+ *
+ * @param string $class
+ *
+ * @return string
+ */
+ protected function classToRelativePhpFilename($class)
+ {
+ return str_replace(
+ self::NAMESPACE_SEPARATOR,
+ DIRECTORY_SEPARATOR,
+ $class
+ ) . '.php';
+ }
+
+ /**
+ * Whether given prefix (Forms, Controllers...) makes part of "application"
+ *
+ * @param string $prefix
+ *
+ * @return boolean
+ */
+ protected function isApplicationPrefix($prefix)
+ {
+ return array_key_exists($prefix, $this->applicationPrefixes);
+ }
+
+ /**
+ * Whether the given namespace registered an application directory
+ *
+ * @return boolean
+ */
+ protected function namespaceHasApplictionDirectory($namespace)
+ {
+ return array_key_exists($namespace, $this->applicationDirectories);
+ }
+
+ /**
+ * Load the given class or interface
+ *
+ * @param string $class Name of the class or interface
+ *
+ * @return bool Whether the class or interface has been loaded
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->getSourceFile($class)) {
+ if (file_exists($file)) {
+ require $file;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Register {@link loadClass()} as an autoloader
+ */
+ public function register()
+ {
+ spl_autoload_register(array($this, 'loadClass'));
+ }
+
+ /**
+ * Unregister {@link loadClass()} as an autoloader
+ */
+ public function unregister()
+ {
+ spl_autoload_unregister(array($this, 'loadClass'));
+ }
+
+ /**
+ * Unregister this as an autoloader
+ */
+ public function __destruct()
+ {
+ $this->unregister();
+ }
+}
diff --git a/library/Icinga/Application/Cli.php b/library/Icinga/Application/Cli.php
new file mode 100644
index 0000000..3b93738
--- /dev/null
+++ b/library/Icinga/Application/Cli.php
@@ -0,0 +1,211 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Application\Platform;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Authentication\Auth;
+use Icinga\Cli\Params;
+use Icinga\Cli\Loader;
+use Icinga\Cli\Screen;
+use Icinga\Application\Logger;
+use Icinga\Application\Benchmark;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ProgrammingError;
+use Icinga\User;
+
+require_once __DIR__ . '/ApplicationBootstrap.php';
+
+class Cli extends ApplicationBootstrap
+{
+ protected $isCli = true;
+
+ protected $params;
+
+ protected $showBenchmark = false;
+
+ protected $watchTimeout;
+
+ protected $cliLoader;
+
+ protected $verbose;
+
+ protected $debug;
+
+ protected function bootstrap()
+ {
+ $this->assertRunningOnCli();
+ $this->setupLogging()
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->loadConfig()
+ ->setupTimezone()
+ ->prepareInternationalization()
+ ->setupInternationalization()
+ ->parseBasicParams()
+ ->setupLogger()
+ ->setupModuleManager()
+ ->setupUserBackendFactory()
+ ->loadSetupModuleIfNecessary()
+ ->setupFakeAuthentication()
+ ->registerApplicationHooks();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setupLogging()
+ {
+ Logger::create(
+ new ConfigObject(
+ array(
+ 'log' => 'stderr'
+ )
+ )
+ );
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setupLogger()
+ {
+ $config = new ConfigObject();
+ $config->log = $this->params->shift('log', 'stderr');
+ if ($config->log === 'file') {
+ $config->file = $this->params->shiftRequired('log-path');
+ } elseif ($config->log === 'syslog') {
+ $config->application = 'icingacli';
+ }
+
+ if ($this->params->get('verbose', false)) {
+ $config->level = Logger::INFO;
+ } elseif ($this->params->get('debug', false)) {
+ $config->level = Logger::DEBUG;
+ } else {
+ $config->level = Logger::WARNING;
+ }
+
+ Logger::create($config);
+ return $this;
+ }
+
+ protected function setupFakeAuthentication()
+ {
+ Auth::getInstance()->setUser(new User('cli'));
+
+ return $this;
+ }
+
+ public function cliLoader()
+ {
+ if ($this->cliLoader === null) {
+ $this->cliLoader = new Loader($this);
+ }
+ return $this->cliLoader;
+ }
+
+ protected function parseBasicParams()
+ {
+ $this->params = Params::parse();
+ if ($this->params->shift('help')) {
+ $this->params->unshift('help');
+ }
+ if ($this->params->shift('version')) {
+ $this->params->unshift('version');
+ }
+ if ($this->params->shift('autocomplete')) {
+ $this->params->unshift('autocomplete');
+ }
+
+ $watch = $this->params->shift('watch');
+ if ($watch === true) {
+ $this->watchTimeout = 5;
+ } elseif (is_numeric($watch)) {
+ $this->watchTimeout = (int) $watch;
+ }
+
+ $this->debug = (int) $this->params->get('debug');
+ $this->verbose = (int) $this->params->get('verbose');
+
+ $this->showBenchmark = (bool) $this->params->shift('benchmark');
+ return $this;
+ }
+
+ public function getParams()
+ {
+ return $this->params;
+ }
+
+ public function dispatchModule($name, $basedir = null)
+ {
+ $this->getModuleManager()->loadModule($name, $basedir);
+ $this->cliLoader()->setModuleName($name);
+ $this->dispatch();
+ }
+
+ public function dispatch()
+ {
+ Benchmark::measure('Dispatching CLI command');
+
+ if ($this->watchTimeout === null) {
+ $this->dispatchOnce();
+ } else {
+ $this->dispatchEndless();
+ }
+ }
+
+ protected function dispatchOnce()
+ {
+ $loader = $this->cliLoader();
+ $loader->parseParams();
+ $result = $loader->dispatch();
+ Benchmark::measure('All done');
+ if ($this->showBenchmark) {
+ Benchmark::dump();
+ }
+ if ($result === false) {
+ exit(3);
+ }
+ }
+
+ protected function dispatchEndless()
+ {
+ $loader = $this->cliLoader();
+ $loader->parseParams();
+ $screen = Screen::instance();
+
+ while (true) {
+ Benchmark::measure('Watch mode - loop begins');
+ ob_start();
+ $params = clone($this->params);
+ $loader->dispatch($params);
+ Benchmark::measure('Dispatch done');
+ if ($this->showBenchmark) {
+ Benchmark::dump();
+ }
+ Benchmark::reset();
+ $out = ob_get_contents();
+ ob_end_clean();
+ echo $screen->clear() . $out;
+ sleep($this->watchTimeout);
+ }
+ }
+
+ /**
+ * Fail if Icinga has not been called on CLI
+ *
+ * @throws ProgrammingError
+ * @return void
+ */
+ protected function assertRunningOnCli()
+ {
+ if (Platform::isCli()) {
+ return;
+ }
+ throw new ProgrammingError('Icinga is not running on CLI');
+ }
+}
diff --git a/library/Icinga/Application/Config.php b/library/Icinga/Application/Config.php
new file mode 100644
index 0000000..80fe3b8
--- /dev/null
+++ b/library/Icinga/Application/Config.php
@@ -0,0 +1,498 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Exception\NotWritableError;
+use Iterator;
+use Countable;
+use LogicException;
+use UnexpectedValueException;
+use Icinga\Util\File;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Selectable;
+use Icinga\Data\SimpleQuery;
+use Icinga\File\Ini\IniWriter;
+use Icinga\File\Ini\IniParser;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotReadableError;
+use Icinga\Web\Navigation\Navigation;
+
+/**
+ * Container for INI like configuration and global registry of application and module related configuration.
+ */
+class Config implements Countable, Iterator, Selectable
+{
+ /**
+ * Configuration directory where ALL (application and module) configuration is located
+ *
+ * @var string
+ */
+ public static $configDir;
+
+ /**
+ * Application config instances per file
+ *
+ * @var array
+ */
+ protected static $app = array();
+
+ /**
+ * Module config instances per file
+ *
+ * @var array
+ */
+ protected static $modules = array();
+
+ /**
+ * Navigation config instances per type
+ *
+ * @var array
+ */
+ protected static $navigation = array();
+
+ /**
+ * The internal ConfigObject
+ *
+ * @var ConfigObject
+ */
+ protected $config;
+
+ /**
+ * The INI file this config has been loaded from or should be written to
+ *
+ * @var string
+ */
+ protected $configFile;
+
+ /**
+ * Create a new config
+ *
+ * @param ConfigObject $config The config object to handle
+ */
+ public function __construct(ConfigObject $config = null)
+ {
+ $this->config = $config !== null ? $config : new ConfigObject();
+ }
+
+ /**
+ * Return this config's file path
+ *
+ * @return string
+ */
+ public function getConfigFile()
+ {
+ return $this->configFile;
+ }
+
+ /**
+ * Set this config's file path
+ *
+ * @param string $filepath The path to the ini file
+ *
+ * @return $this
+ */
+ public function setConfigFile($filepath)
+ {
+ $this->configFile = $filepath;
+ return $this;
+ }
+
+ /**
+ * Return the internal ConfigObject
+ *
+ * @return ConfigObject
+ */
+ public function getConfigObject()
+ {
+ return $this->config;
+ }
+
+ /**
+ * Provide a query for the internal config object
+ *
+ * @return SimpleQuery
+ */
+ public function select()
+ {
+ return $this->config->select();
+ }
+
+ /**
+ * Return the count of available sections
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return $this->select()->count();
+ }
+
+ /**
+ * Reset the current position of the internal config object
+ *
+ * @return void
+ */
+ public function rewind(): void
+ {
+ $this->config->rewind();
+ }
+
+ /**
+ * Return the section of the current iteration
+ *
+ * @return ConfigObject
+ */
+ public function current(): ConfigObject
+ {
+ return $this->config->current();
+ }
+
+ /**
+ * Return whether the position of the current iteration is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ return $this->config->valid();
+ }
+
+ /**
+ * Return the section's name of the current iteration
+ *
+ * @return string
+ */
+ public function key(): string
+ {
+ return $this->config->key();
+ }
+
+ /**
+ * Advance the position of the current iteration and return the new section
+ *
+ * @return void
+ */
+ public function next(): void
+ {
+ $this->config->next();
+ }
+
+ /**
+ * Return whether this config has any sections
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return $this->config->isEmpty();
+ }
+
+ /**
+ * Return this config's section names
+ *
+ * @return array
+ */
+ public function keys()
+ {
+ return $this->config->keys();
+ }
+
+ /**
+ * Return this config's data as associative array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ return $this->config->toArray();
+ }
+
+ /**
+ * Return the value from a section's property
+ *
+ * @param string $section The section where the given property can be found
+ * @param string $key The section's property to fetch the value from
+ * @param mixed $default The value to return in case the section or the property is missing
+ *
+ * @return mixed
+ *
+ * @throws UnexpectedValueException In case the given section does not hold any configuration
+ */
+ public function get($section, $key, $default = null)
+ {
+ $value = $this->config->$section;
+ if ($value instanceof ConfigObject) {
+ $value = $value->$key;
+ } elseif ($value !== null) {
+ throw new UnexpectedValueException(
+ sprintf('Value "%s" is not of type "%s" or a sub-type of it', $value, get_class($this->config))
+ );
+ }
+
+ if ($value === null && $default !== null) {
+ $value = $default;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Return the given section
+ *
+ * @param string $name The section's name
+ *
+ * @return ConfigObject
+ */
+ public function getSection($name)
+ {
+ $section = $this->config->get($name);
+ return $section !== null ? $section : new ConfigObject();
+ }
+
+ /**
+ * Set or replace a section
+ *
+ * @param string $name
+ * @param array|ConfigObject $config
+ *
+ * @return $this
+ */
+ public function setSection($name, $config = null)
+ {
+ if ($config === null) {
+ $config = new ConfigObject();
+ } elseif (! $config instanceof ConfigObject) {
+ $config = new ConfigObject($config);
+ }
+
+ $this->config->$name = $config;
+ return $this;
+ }
+
+ /**
+ * Remove a section
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function removeSection($name)
+ {
+ unset($this->config->$name);
+ return $this;
+ }
+
+ /**
+ * Return whether the given section exists
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasSection($name)
+ {
+ return isset($this->config->$name);
+ }
+
+ /**
+ * Initialize a new config using the given array
+ *
+ * The returned config has no file associated to it.
+ *
+ * @param array $array The array to initialize the config with
+ *
+ * @return Config
+ */
+ public static function fromArray(array $array)
+ {
+ return new static(new ConfigObject($array));
+ }
+
+ /**
+ * Load configuration from the given INI file
+ *
+ * @param string $file The file to parse
+ *
+ * @throws NotReadableError When the file cannot be read
+ */
+ public static function fromIni($file)
+ {
+ $emptyConfig = new static();
+
+ $filepath = realpath($file);
+ if ($filepath === false) {
+ $emptyConfig->setConfigFile($file);
+ } elseif (is_readable($filepath)) {
+ return IniParser::parseIniFile($filepath);
+ } elseif (@file_exists($filepath)) {
+ throw new NotReadableError(t('Cannot read config file "%s". Permission denied'), $filepath);
+ }
+
+ return $emptyConfig;
+ }
+
+ /**
+ * Save configuration to the given INI file
+ *
+ * @param string|null $filePath The path to the INI file or null in case this config's path should be used
+ * @param int $fileMode The file mode to store the file with
+ *
+ * @throws LogicException In case this config has no path and none is passed in either
+ * @throws NotWritableError In case the INI file cannot be written
+ *
+ * @todo create basepath and throw NotWritableError in case its not possible
+ */
+ public function saveIni($filePath = null, $fileMode = 0660)
+ {
+ if ($filePath === null && $this->configFile) {
+ $filePath = $this->configFile;
+ } elseif ($filePath === null) {
+ throw new LogicException('You need to pass $filePath or set a path using Config::setConfigFile()');
+ }
+
+ if (! file_exists($filePath)) {
+ File::create($filePath, $fileMode);
+ }
+
+ $this->getIniWriter($filePath, $fileMode)->write();
+ }
+
+ /**
+ * Return a IniWriter for this config
+ *
+ * @param string|null $filePath
+ * @param int $fileMode
+ *
+ * @return IniWriter
+ */
+ protected function getIniWriter($filePath = null, $fileMode = null)
+ {
+ return new IniWriter($this, $filePath, $fileMode);
+ }
+
+ /**
+ * Prepend configuration base dir to the given relative path
+ *
+ * @param string $path A relative path
+ *
+ * @return string
+ */
+ public static function resolvePath($path)
+ {
+ return self::$configDir . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
+ }
+
+ /**
+ * Retrieve a application config
+ *
+ * @param string $configname The configuration name (without ini suffix) to read and return
+ * @param bool $fromDisk When set true, the configuration will be read from disk, even
+ * if it already has been read
+ *
+ * @return Config The requested configuration
+ */
+ public static function app($configname = 'config', $fromDisk = false)
+ {
+ if (! isset(self::$app[$configname]) || $fromDisk) {
+ self::$app[$configname] = static::fromIni(static::resolvePath($configname . '.ini'));
+ }
+
+ return self::$app[$configname];
+ }
+
+ /**
+ * Retrieve a module config
+ *
+ * @param string $modulename The name of the module where to look for the requested configuration
+ * @param string $configname The configuration name (without ini suffix) to read and return
+ * @param bool $fromDisk When set true, the configuration will be read from disk, even
+ * if it already has been read
+ *
+ * @return Config The requested configuration
+ */
+ public static function module($modulename, $configname = 'config', $fromDisk = false)
+ {
+ if (! isset(self::$modules[$modulename])) {
+ self::$modules[$modulename] = array();
+ }
+
+ if (! isset(self::$modules[$modulename][$configname]) || $fromDisk) {
+ self::$modules[$modulename][$configname] = static::fromIni(
+ static::resolvePath('modules/' . $modulename . '/' . $configname . '.ini')
+ );
+ }
+ return self::$modules[$modulename][$configname];
+ }
+
+ /**
+ * Retrieve a navigation config
+ *
+ * @param string $type The type identifier of the navigation item for which to return its config
+ * @param string $username A user's name or null if the shared config is desired
+ * @param bool $fromDisk If true, the configuration will be read from disk
+ *
+ * @return Config The requested configuration
+ */
+ public static function navigation($type, $username = null, $fromDisk = false)
+ {
+ if (! isset(self::$navigation[$type])) {
+ self::$navigation[$type] = array();
+ }
+
+ $branch = $username ?: 'shared';
+ $typeConfigs = self::$navigation[$type];
+ if (! isset($typeConfigs[$branch]) || $fromDisk) {
+ $typeConfigs[$branch] = static::fromIni(static::getNavigationConfigPath($type, $username));
+ }
+
+ return $typeConfigs[$branch];
+ }
+
+ /**
+ * Return the path to the configuration file for the given navigation item type and user
+ *
+ * @param string $type
+ * @param string $username
+ *
+ * @return string
+ *
+ * @throws IcingaException In case the given type is unknown
+ */
+ protected static function getNavigationConfigPath($type, $username = null)
+ {
+ $itemTypeConfig = Navigation::getItemTypeConfiguration();
+ if (! isset($itemTypeConfig[$type])) {
+ throw new IcingaException('Invalid navigation item type %s provided', $type);
+ }
+
+ if (isset($itemTypeConfig[$type]['config'])) {
+ $filename = $itemTypeConfig[$type]['config'] . '.ini';
+ } else {
+ $filename = $type . 's.ini';
+ }
+
+ if ($username) {
+ $path = static::resolvePath(implode(DIRECTORY_SEPARATOR, array('preferences', $username, $filename)));
+ if (realpath($path) === false) {
+ $path = static::resolvePath(implode(
+ DIRECTORY_SEPARATOR,
+ array('preferences', strtolower($username), $filename)
+ ));
+ }
+ } else {
+ $path = static::resolvePath('navigation' . DIRECTORY_SEPARATOR . $filename);
+ }
+ return $path;
+ }
+
+ /**
+ * Return this config rendered as a INI structured string
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->getIniWriter()->render();
+ }
+}
diff --git a/library/Icinga/Application/EmbeddedWeb.php b/library/Icinga/Application/EmbeddedWeb.php
new file mode 100644
index 0000000..9adb3a4
--- /dev/null
+++ b/library/Icinga/Application/EmbeddedWeb.php
@@ -0,0 +1,115 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+require_once dirname(__FILE__) . '/ApplicationBootstrap.php';
+
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+use ipl\I18n\NoopTranslator;
+use ipl\I18n\StaticTranslator;
+
+/**
+ * Use this if you want to make use of Icinga functionality in other web projects
+ *
+ * Usage example:
+ * <code>
+ * use Icinga\Application\EmbeddedWeb;
+ * EmbeddedWeb::start();
+ * </code>
+ */
+class EmbeddedWeb extends ApplicationBootstrap
+{
+ /**
+ * Request
+ *
+ * @var Request
+ */
+ protected $request;
+
+ /**
+ * Response
+ *
+ * @var Response
+ */
+ protected $response;
+
+ /**
+ * Get the request
+ *
+ * @return Request
+ */
+ public function getRequest()
+ {
+ return $this->request;
+ }
+
+ /**
+ * Get the response
+ *
+ * @return Response
+ */
+ public function getResponse()
+ {
+ return $this->response;
+ }
+
+ /**
+ * Embedded bootstrap parts
+ *
+ * @see ApplicationBootstrap::bootstrap
+ *
+ * @return $this
+ */
+ protected function bootstrap()
+ {
+ return $this
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->loadConfig()
+ ->setupLogging()
+ ->setupLogger()
+ ->setupRequest()
+ ->setupResponse()
+ ->setupTimezone()
+ ->prepareFakeInternationalization()
+ ->setupModuleManager()
+ ->loadEnabledModules()
+ ->registerApplicationHooks();
+ }
+
+ /**
+ * Set the request
+ *
+ * @return $this
+ */
+ protected function setupRequest()
+ {
+ $this->request = new Request();
+ return $this;
+ }
+
+ /**
+ * Set the response
+ *
+ * @return $this
+ */
+ protected function setupResponse()
+ {
+ $this->response = new Response();
+ return $this;
+ }
+
+ /**
+ * Prepare fake internationalization
+ *
+ * @return $this
+ */
+ protected function prepareFakeInternationalization()
+ {
+ StaticTranslator::$instance = new NoopTranslator();
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Hook.php b/library/Icinga/Application/Hook.php
new file mode 100644
index 0000000..9720c6a
--- /dev/null
+++ b/library/Icinga/Application/Hook.php
@@ -0,0 +1,328 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Exception;
+use Icinga\Authentication\Auth;
+use Icinga\Application\Modules\Manager;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Icinga Hook registry
+ *
+ * Modules making use of predefined hooks have to use this registry
+ *
+ * Usage:
+ * <code>
+ * Hook::register('grapher', 'My\\Grapher\\Class');
+ * </code>
+ */
+class Hook
+{
+ /**
+ * Our hook name registry
+ *
+ * @var array
+ */
+ protected static $hooks = array();
+
+ /**
+ * Hooks that have already been instantiated
+ *
+ * @var array
+ */
+ protected static $instances = array();
+
+ /**
+ * Namespace prefix
+ *
+ * @var string
+ */
+ public static $BASE_NS = 'Icinga\\Application\\Hook\\';
+
+ /**
+ * Append this string to base class
+ *
+ * All base classes renamed to *Hook
+ *
+ * @var string
+ */
+ public static $classSuffix = 'Hook';
+
+ /**
+ * Reset object state
+ */
+ public static function clean()
+ {
+ self::$hooks = array();
+ self::$instances = array();
+ self::$BASE_NS = 'Icinga\\Application\\Hook\\';
+ }
+
+ /**
+ * Whether someone registered itself for the given hook name
+ *
+ * @param string $name One of the predefined hook names
+ *
+ * @return bool
+ */
+ public static function has($name)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (! array_key_exists($name, self::$hooks)) {
+ return false;
+ }
+
+ foreach (self::$hooks[$name] as $hook) {
+ list($class, $alwaysRun) = $hook;
+ if ($alwaysRun || self::hasPermission($class)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected static function normalizeHookName($name)
+ {
+ if (strpos($name, '\\') === false) {
+ $parts = explode('/', $name);
+ foreach ($parts as & $part) {
+ $part = ucfirst($part);
+ }
+
+ return implode('\\', $parts);
+ }
+
+ return $name;
+ }
+
+ /**
+ * Create or return an instance of a given hook
+ *
+ * TODO: Should return some kind of a hook interface
+ *
+ * @param string $name One of the predefined hook names
+ * @param string $key The identifier of a specific subtype
+ *
+ * @return mixed
+ */
+ public static function createInstance($name, $key)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (!self::has($name)) {
+ return null;
+ }
+
+ if (isset(self::$instances[$name][$key])) {
+ return self::$instances[$name][$key];
+ }
+
+ $class = self::$hooks[$name][$key][0];
+
+ if (! class_exists($class)) {
+ throw new ProgrammingError(
+ 'Erraneous hook implementation, class "%s" does not exist',
+ $class
+ );
+ }
+ try {
+ $instance = new $class();
+ } catch (Exception $e) {
+ Logger::debug(
+ 'Hook "%s" (%s) (%s) failed, will be unloaded: %s',
+ $name,
+ $key,
+ $class,
+ $e->getMessage()
+ );
+ // TODO: Persist unloading for "some time" or "current session"
+ unset(self::$hooks[$name][$key]);
+ return null;
+ }
+
+ self::assertValidHook($instance, $name);
+ self::$instances[$name][$key] = $instance;
+ return $instance;
+ }
+
+ protected static function splitHookName($name)
+ {
+ $sep = '\\';
+ if (false === $module = strpos($name, $sep)) {
+ return array(null, $name);
+ }
+ return array(
+ substr($name, 0, $module),
+ substr($name, $module + 1)
+ );
+ }
+
+ /**
+ * Extract the Icinga module name from a given namespaced class name
+ *
+ * Does no validation, prefix must have been checked before
+ *
+ * Shameless copy of ClassLoader::extractModuleName()
+ *
+ * @param string $class The hook's class path
+ *
+ * @return string
+ */
+ protected static function extractModuleName($class)
+ {
+ return lcfirst(
+ substr(
+ $class,
+ ClassLoader::MODULE_PREFIX_LENGTH,
+ strpos(
+ $class,
+ ClassLoader::NAMESPACE_SEPARATOR,
+ ClassLoader::MODULE_PREFIX_LENGTH + 1
+ ) - ClassLoader::MODULE_PREFIX_LENGTH
+ )
+ );
+ }
+
+ /**
+ * Return whether the user has the permission to access the module which provides the given hook
+ *
+ * @param string $class The hook's class path
+ *
+ * @return bool
+ */
+ protected static function hasPermission($class)
+ {
+ if (Icinga::app()->isCli()) {
+ return true;
+ }
+
+ return Auth::getInstance()->hasPermission(
+ Manager::MODULE_PERMISSION_NS . self::extractModuleName($class)
+ );
+ }
+
+ /**
+ * Test for a valid class name
+ *
+ * @param mixed $instance
+ * @param string $name
+ *
+ * @throws ProgrammingError
+ */
+ private static function assertValidHook($instance, $name)
+ {
+ $name = self::normalizeHookName($name);
+
+ $suffix = self::$classSuffix; // 'Hook'
+ $base = self::$BASE_NS; // 'Icinga\\Web\\Hook\\'
+
+ list($module, $name) = self::splitHookName($name);
+
+ if ($module === null) {
+ $base_class = $base . ucfirst($name) . 'Hook';
+
+ // I'm unsure whether this makes sense. Unused and Wrong.
+ if (strpos($base_class, $suffix) === false) {
+ $base_class .= $suffix;
+ }
+ } else {
+ $base_class = 'Icinga\\Module\\'
+ . ucfirst($module)
+ . '\\Hook\\'
+ . ucfirst($name)
+ . $suffix;
+ }
+
+ if (!$instance instanceof $base_class) {
+ // This is a compatibility check. Should be removed one far day:
+ if ($module !== null) {
+ $compat_class = 'Icinga\\Module\\'
+ . ucfirst($module)
+ . '\\Web\\Hook\\'
+ . ucfirst($name)
+ . $suffix;
+
+ if ($instance instanceof $compat_class) {
+ return;
+ }
+ }
+
+ throw new ProgrammingError(
+ '%s is not an instance of %s',
+ get_class($instance),
+ $base_class
+ );
+ }
+ }
+
+ /**
+ * Return all instances of a specific name
+ *
+ * @param string $name One of the predefined hook names
+ *
+ * @return array
+ */
+ public static function all($name): array
+ {
+ $name = self::normalizeHookName($name);
+ if (! self::has($name)) {
+ return [];
+ }
+
+ foreach (self::$hooks[$name] as $key => $hook) {
+ list($class, $alwaysRun) = $hook;
+ if ($alwaysRun || self::hasPermission($class)) {
+ self::createInstance($name, $key);
+ }
+ }
+
+ return self::$instances[$name] ?? [];
+ }
+
+ /**
+ * Get the first hook
+ *
+ * @param string $name One of the predefined hook names
+ *
+ * @return null|mixed
+ */
+ public static function first($name)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (self::has($name)) {
+ foreach (self::$hooks[$name] as $key => $hook) {
+ list($class, $alwaysRun) = $hook;
+ if ($alwaysRun || self::hasPermission($class)) {
+ return self::createInstance($name, $key);
+ }
+ }
+ }
+ }
+
+ /**
+ * Register a class
+ *
+ * @param string $name One of the predefined hook names
+ * @param string $key The identifier of a specific subtype
+ * @param string $class Your class name, must inherit one of the
+ * classes in the Icinga/Application/Hook folder
+ * @param bool $alwaysRun To run the hook always (e.g. without permission check)
+ */
+ public static function register($name, $key, $class, $alwaysRun = false)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (!isset(self::$hooks[$name])) {
+ self::$hooks[$name] = array();
+ }
+
+ $class = ltrim($class, ClassLoader::NAMESPACE_SEPARATOR);
+
+ self::$hooks[$name][$key] = [$class, $alwaysRun];
+ }
+}
diff --git a/library/Icinga/Application/Hook/ApplicationStateHook.php b/library/Icinga/Application/Hook/ApplicationStateHook.php
new file mode 100644
index 0000000..be973fe
--- /dev/null
+++ b/library/Icinga/Application/Hook/ApplicationStateHook.php
@@ -0,0 +1,90 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+
+/**
+ * Application state hook base class
+ */
+abstract class ApplicationStateHook
+{
+ const ERROR = 'error';
+
+ private $messages = [];
+
+ final public function hasMessages()
+ {
+ return ! empty($this->messages);
+ }
+
+ final public function getMessages()
+ {
+ return $this->messages;
+ }
+
+ /**
+ * Add an error message
+ *
+ * The timestamp of the message is used for deduplication and thus must refer to the time when the error first
+ * occurred. Don't use {@link time()} here!
+ *
+ * @param string $id ID of the message. The ID must be prefixed with the module name
+ * @param int $timestamp Timestamp when the error first occurred
+ * @param string $message Error message
+ *
+ * @return $this
+ */
+ final public function addError($id, $timestamp, $message)
+ {
+ $id = trim($id);
+ $timestamp = (int) $timestamp;
+
+ if (! strlen($id)) {
+ throw new \InvalidArgumentException('ID expected.');
+ }
+
+ if (! $timestamp) {
+ throw new \InvalidArgumentException('Timestamp expected.');
+ }
+
+ $this->messages[sha1($id . $timestamp)] = [self::ERROR, $timestamp, $message];
+
+ return $this;
+ }
+
+ /**
+ * Override this method in order to provide application state messages
+ */
+ abstract public function collectMessages();
+
+ final public static function getAllMessages()
+ {
+ $messages = [];
+
+ if (! Hook::has('ApplicationState')) {
+ return $messages;
+ }
+
+ foreach (Hook::all('ApplicationState') as $hook) {
+ /** @var self $hook */
+ try {
+ $hook->collectMessages();
+ } catch (\Exception $e) {
+ Logger::error(
+ "Failed to collect messages from hook '%s'. An error occurred: %s",
+ get_class($hook),
+ $e
+ );
+ }
+
+ if ($hook->hasMessages()) {
+ $messages += $hook->getMessages();
+ }
+ }
+
+ return $messages;
+ }
+}
diff --git a/library/Icinga/Application/Hook/AuditHook.php b/library/Icinga/Application/Hook/AuditHook.php
new file mode 100644
index 0000000..e6209da
--- /dev/null
+++ b/library/Icinga/Application/Hook/AuditHook.php
@@ -0,0 +1,123 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Exception;
+use InvalidArgumentException;
+use Icinga\Authentication\Auth;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+
+abstract class AuditHook
+{
+ /**
+ * Log an activity to the audit log
+ *
+ * Propagates the given message details to all known hook implementations.
+ *
+ * @param string $type An arbitrary name identifying the type of activity
+ * @param string $message A detailed description possibly referencing parameters in $data
+ * @param array $data Additional information (How this is stored or used is up to each implementation)
+ * @param string $identity An arbitrary name identifying the responsible subject, defaults to the current user
+ * @param int $time A timestamp defining when the activity occurred, defaults to now
+ */
+ public static function logActivity($type, $message, array $data = null, $identity = null, $time = null)
+ {
+ if (! Hook::has('audit')) {
+ return;
+ }
+
+ if ($identity === null) {
+ $identity = Auth::getInstance()->getUser()->getUsername();
+ }
+
+ if ($time === null) {
+ $time = time();
+ }
+
+ foreach (Hook::all('audit') as $hook) {
+ /** @var self $hook */
+ try {
+ $formattedMessage = $message;
+ if ($data !== null) {
+ // Calling formatMessage on each hook is intended and allows
+ // intercepting message formatting while keeping it implicit
+ $formattedMessage = $hook->formatMessage($message, $data);
+ }
+
+ $hook->logMessage($time, $identity, $type, $formattedMessage, $data);
+ } catch (Exception $e) {
+ Logger::error(
+ 'Failed to propagate audit message to hook "%s". An error occurred: %s',
+ get_class($hook),
+ $e
+ );
+ }
+ }
+ }
+
+ /**
+ * Log a message to the audit log
+ *
+ * @param int $time A timestamp defining when the activity occurred
+ * @param string $identity An arbitrary name identifying the responsible subject
+ * @param string $type An arbitrary name identifying the type of activity
+ * @param string $message A detailed description of the activity
+ * @param array $data Additional activity information
+ */
+ abstract public function logMessage($time, $identity, $type, $message, array $data = null);
+
+ /**
+ * Substitute the given message with its accompanying data
+ *
+ * @param string $message
+ * @param array $messageData
+ *
+ * @return string
+ */
+ public function formatMessage($message, array $messageData)
+ {
+ return preg_replace_callback('/{{(.+?)}}/', function ($match) use ($messageData) {
+ return $this->extractMessageValue(explode('.', $match[1]), $messageData);
+ }, $message);
+ }
+
+ /**
+ * Extract the given value path from the given message data
+ *
+ * @param array $path
+ * @param array $messageData
+ *
+ * @return mixed
+ *
+ * @throws InvalidArgumentException In case of an invalid or missing format parameter
+ */
+ protected function extractMessageValue(array $path, array $messageData)
+ {
+ $key = array_shift($path);
+ if (array_key_exists($key, $messageData)) {
+ $value = $messageData[$key];
+ } else {
+ throw new InvalidArgumentException("Missing format parameter '$key'");
+ }
+
+ if (empty($path)) {
+ if (! is_scalar($value)) {
+ throw new InvalidArgumentException(
+ 'Invalid format parameter. Expected scalar for path "' . join('.', $path) . '".'
+ . ' Got "' . gettype($value) . '" instead'
+ );
+ }
+
+ return $value;
+ } elseif (! is_array($value)) {
+ throw new InvalidArgumentException(
+ 'Invalid format parameter. Expected array for path "'. join('.', $path) . '".'
+ . ' Got "' . gettype($value) . '" instead'
+ );
+ }
+
+ return $this->extractMessageValue($path, $value);
+ }
+}
diff --git a/library/Icinga/Application/Hook/AuthenticationHook.php b/library/Icinga/Application/Hook/AuthenticationHook.php
new file mode 100644
index 0000000..41cc661
--- /dev/null
+++ b/library/Icinga/Application/Hook/AuthenticationHook.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Application\Hook;
+
+use Icinga\User;
+use Icinga\Web\Hook;
+use Icinga\Application\Logger;
+
+/**
+ * Icinga Web Authentication Hook base class
+ *
+ * This hook can be used to authenticate the user in a third party application.
+ * Extend this class if you want to perform arbitrary actions during the login and logout.
+ */
+abstract class AuthenticationHook
+{
+ /**
+ * Name of the hook
+ */
+ const NAME = 'authentication';
+
+ /**
+ * Triggered after login in Icinga Web and when calling login action even if already authenticated in Icinga Web
+ *
+ * @param User $user
+ */
+ public function onLogin(User $user)
+ {
+ }
+
+ /**
+ * Triggered before logout from Icinga Web
+ *
+ * @param User $user
+ */
+ public function onLogout(User $user)
+ {
+ }
+
+ /**
+ * Call the onLogin() method of all registered AuthHook(s)
+ *
+ * @param User $user
+ */
+ public static function triggerLogin(User $user)
+ {
+ /** @var AuthenticationHook $hook */
+ foreach (Hook::all(self::NAME) as $hook) {
+ try {
+ $hook->onLogin($user);
+ } catch (\Exception $e) {
+ // Avoid error propagation if login failed in third party application
+ Logger::error($e);
+ }
+ }
+ }
+
+ /**
+ * Call the onLogout() method of all registered AuthHook(s)
+ *
+ * @param User $user
+ */
+ public static function triggerLogout(User $user)
+ {
+ /** @var AuthenticationHook $hook */
+ foreach (Hook::all(self::NAME) as $hook) {
+ try {
+ $hook->onLogout($user);
+ } catch (\Exception $e) {
+ // Avoid error propagation if login failed in third party application
+ Logger::error($e);
+ }
+ }
+ }
+}
diff --git a/library/Icinga/Application/Hook/Common/DbMigrationStep.php b/library/Icinga/Application/Hook/Common/DbMigrationStep.php
new file mode 100644
index 0000000..54a1139
--- /dev/null
+++ b/library/Icinga/Application/Hook/Common/DbMigrationStep.php
@@ -0,0 +1,129 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook\Common;
+
+use ipl\Sql\Connection;
+use RuntimeException;
+
+class DbMigrationStep
+{
+ /** @var string The sql script version the queries are loaded from */
+ protected $version;
+
+ /** @var string */
+ protected $scriptPath;
+
+ /** @var ?string */
+ protected $description;
+
+ /** @var ?string */
+ protected $lastState;
+
+ public function __construct(string $version, string $scriptPath)
+ {
+ $this->scriptPath = $scriptPath;
+ $this->version = $version;
+ }
+
+ /**
+ * Get the sql script version the queries are loaded from
+ *
+ * @return string
+ */
+ public function getVersion(): string
+ {
+ return $this->version;
+ }
+
+ /**
+ * Get upgrade script relative path name
+ *
+ * @return string
+ */
+ public function getScriptPath(): string
+ {
+ return $this->scriptPath;
+ }
+
+ /**
+ * Get the description of this database migration if any
+ *
+ * @return ?string
+ */
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ /**
+ * Set the description of this database migration
+ *
+ * @param ?string $description
+ *
+ * @return DbMigrationStep
+ */
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ /**
+ * Get the last error message of this hook if any
+ *
+ * @return ?string
+ */
+ public function getLastState(): ?string
+ {
+ return $this->lastState;
+ }
+
+ /**
+ * Set the last error message
+ *
+ * @param ?string $message
+ *
+ * @return $this
+ */
+ public function setLastState(?string $message): self
+ {
+ $this->lastState = $message;
+
+ return $this;
+ }
+
+ /**
+ * Perform the sql migration
+ *
+ * @param Connection $conn
+ *
+ * @return $this
+ *
+ * @throws RuntimeException Throws an error in case of any database errors or when there is nothing to migrate
+ */
+ public function apply(Connection $conn): self
+ {
+ $statements = @file_get_contents($this->getScriptPath());
+ if ($statements === false) {
+ throw new RuntimeException(sprintf('Cannot load upgrade script %s', $this->getScriptPath()));
+ }
+
+ if (empty($statements)) {
+ throw new RuntimeException('Nothing to migrate');
+ }
+
+ if (preg_match('/\s*delimiter\s*(\S+)\s*$/im', $statements, $matches)) {
+ /** @var string $statements */
+ $statements = preg_replace('/\s*delimiter\s*(\S+)\s*$/im', '', $statements);
+ /** @var string $statements */
+ $statements = preg_replace('/' . preg_quote($matches[1], '/') . '$/m', ';', $statements);
+ }
+
+ $conn->exec($statements);
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Hook/ConfigFormEventsHook.php b/library/Icinga/Application/Hook/ConfigFormEventsHook.php
new file mode 100644
index 0000000..05fa05d
--- /dev/null
+++ b/library/Icinga/Application/Hook/ConfigFormEventsHook.php
@@ -0,0 +1,137 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Web\Form;
+
+/**
+ * Base class for config form event hooks
+ */
+abstract class ConfigFormEventsHook
+{
+ /** @var array Array of errors found while processing the form event hooks */
+ private static $lastErrors = [];
+
+ /**
+ * Get whether the hook applies to the given config form
+ *
+ * @param Form $form
+ *
+ * @return bool
+ */
+ public function appliesTo(Form $form)
+ {
+ return false;
+ }
+
+ /**
+ * isValid event hook
+ *
+ * Implement this method in order to run code after the form has been validated successfully.
+ * Throw an exception here if either the form is not valid or you want interrupt the form handling.
+ * The exception's message will be automatically added as form error message so that it will be
+ * displayed in the frontend.
+ *
+ * @param Form $form
+ *
+ * @throws \Exception If either the form is not valid or to interrupt the form handling
+ */
+ public function isValid(Form $form)
+ {
+ }
+
+ /**
+ * onSuccess event hook
+ *
+ * Implement this method in order to run code after the configuration form has been stored successfully.
+ * You can't interrupt the form handling here. Any exception will be caught, logged and notified.
+ *
+ * @param Form $form
+ */
+ public function onSuccess(Form $form)
+ {
+ }
+
+ /**
+ * Get an array of errors found while processing the form event hooks
+ *
+ * @return array
+ */
+ final public static function getLastErrors()
+ {
+ return self::$lastErrors;
+ }
+
+ /**
+ * Run all isValid hooks
+ *
+ * @param Form $form
+ *
+ * @return bool Returns false if any hook threw an exception
+ */
+ final public static function runIsValid(Form $form)
+ {
+ return self::runEventMethod('isValid', $form);
+ }
+
+ /**
+ * Run all onSuccess hooks
+ *
+ * @param Form $form
+ *
+ * @return bool Returns false if any hook threw an exception
+ */
+ final public static function runOnSuccess(Form $form)
+ {
+ return self::runEventMethod('onSuccess', $form);
+ }
+
+ private static function runEventMethod($eventMethod, Form $form)
+ {
+ self::$lastErrors = [];
+
+ if (! Hook::has('ConfigFormEvents')) {
+ return true;
+ }
+
+ $success = true;
+
+ foreach (Hook::all('ConfigFormEvents') as $hook) {
+ /** @var self $hook */
+ if (! $hook->runAppliesTo($form)) {
+ continue;
+ }
+
+ try {
+ $hook->$eventMethod($form);
+ } catch (\Exception $e) {
+ self::$lastErrors[] = $e->getMessage();
+
+ Logger::error("%s\n%s", $e, IcingaException::getConfidentialTraceAsString($e));
+
+ $success = false;
+ }
+ }
+
+ return $success;
+ }
+
+ private function runAppliesTo(Form $form)
+ {
+ try {
+ $appliesTo = $this->appliesTo($form);
+ } catch (\Exception $e) {
+ // Don't save exception to last errors because we do not want to disturb the user for messed up
+ // appliesTo checks
+ Logger::error("%s\n%s", $e, IcingaException::getConfidentialTraceAsString($e));
+
+ $appliesTo = false;
+ }
+
+ return $appliesTo === true;
+ }
+}
diff --git a/library/Icinga/Application/Hook/DbMigrationHook.php b/library/Icinga/Application/Hook/DbMigrationHook.php
new file mode 100644
index 0000000..f34bc0d
--- /dev/null
+++ b/library/Icinga/Application/Hook/DbMigrationHook.php
@@ -0,0 +1,421 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Countable;
+use DateTime;
+use DirectoryIterator;
+use Exception;
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Modules\Module;
+use Icinga\Model\Schema;
+use Icinga\Web\Session;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Connection;
+use ipl\Stdlib\Filter;
+use PDO;
+use SplFileInfo;
+use stdClass;
+
+/**
+ * Allows you to automatically perform database migrations.
+ *
+ * The version numbers of the sql migrations are determined by extracting the respective migration script names.
+ * It's required to place the sql migrate scripts below the respective following directories:
+ *
+ * `{IcingaApp,Module}::baseDir()/schema/{mysql,pgsql}-upgrades`
+ */
+abstract class DbMigrationHook implements Countable
+{
+ use Translation;
+
+ public const MYSQL_UPGRADE_DIR = 'schema/mysql-upgrades';
+
+ public const PGSQL_UPGRADE_DIR = 'schema/pgsql-upgrades';
+
+ /** @var string Fakes a module when this hook is implemented by the framework itself */
+ public const DEFAULT_MODULE = 'icingaweb2';
+
+ /** @var string Migration hook param name */
+ public const MIGRATION_PARAM = 'migration';
+
+ public const ALL_MIGRATIONS = 'all-migrations';
+
+ /** @var ?array<string, DbMigrationStep> All pending database migrations of this hook */
+ protected $migrations;
+
+ /** @var ?string The current version of this hook */
+ protected $version;
+
+ /**
+ * Get whether the specified table exists in the given database
+ *
+ * @param Connection $conn
+ * @param string $table
+ *
+ * @return bool
+ */
+ public static function tableExists(Connection $conn, string $table): bool
+ {
+ /** @var false|int $exists */
+ $exists = $conn->prepexec(
+ 'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result',
+ $table
+ )->fetchColumn();
+
+ return (bool) $exists;
+ }
+
+ /**
+ * Get whether the specified column exists in the provided table
+ *
+ * @param Connection $conn
+ * @param string $table
+ * @param string $column
+ *
+ * @return ?string
+ */
+ public static function getColumnType(Connection $conn, string $table, string $column): ?string
+ {
+ $pdoStmt = $conn->prepexec(
+ sprintf(
+ 'SELECT %s AS column_type, %s AS column_length FROM information_schema.columns'
+ . ' WHERE table_name = ? AND column_name = ?',
+ $conn->getAdapter() instanceof Pgsql ? 'udt_name' : 'column_type',
+ $conn->getAdapter() instanceof Pgsql ? 'character_maximum_length' : 'NULL'
+ ),
+ [$table, $column]
+ );
+
+ /** @var false|stdClass $result */
+ $result = $pdoStmt->fetch(PDO::FETCH_OBJ);
+ if ($result === false) {
+ return null;
+ }
+
+ if ($result->column_length !== null) {
+ $result->column_type .= '(' . $result->column_length . ')';
+ }
+
+ return $result->column_type;
+ }
+
+ /**
+ * Get the mysql collation name of the given column of the specified table
+ *
+ * @param Connection $conn
+ * @param string $table
+ * @param string $column
+ *
+ * @return ?string
+ */
+ public static function getColumnCollation(Connection $conn, string $table, string $column): ?string
+ {
+ if ($conn->getAdapter() instanceof Pgsql) {
+ return null;
+ }
+
+ /** @var false|string $collation */
+ $collation = $conn->prepexec(
+ 'SELECT collation_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
+ [$table, $column]
+ )->fetchColumn();
+
+ return ! $collation ? null : $collation;
+ }
+
+ /**
+ * Get statically provided descriptions of the individual migrate scripts
+ *
+ * @return string[]
+ */
+ abstract public function providedDescriptions(): array;
+
+ /**
+ * Get the full name of the component this hook is implemented by
+ *
+ * @return string
+ */
+ abstract public function getName(): string;
+
+ /**
+ * Get the current schema version of this migration hook
+ *
+ * @return string
+ */
+ abstract public function getVersion(): string;
+
+ /**
+ * Get a database connection
+ *
+ * @return Connection
+ */
+ abstract public function getDb(): Connection;
+
+ /**
+ * Get all the pending migrations of this hook
+ *
+ * @return DbMigrationStep[]
+ */
+ public function getMigrations(): array
+ {
+ if ($this->migrations === null) {
+ $this->migrations = [];
+
+ $this->load();
+ }
+
+ return $this->migrations ?? [];
+ }
+
+ /**
+ * Get the latest migrations limited by the given number
+ *
+ * @param int $limit
+ *
+ * @return DbMigrationStep[]
+ */
+ public function getLatestMigrations(int $limit): array
+ {
+ $migrations = $this->getMigrations();
+ if ($limit > 0) {
+ $migrations = array_slice($migrations, -$limit, null, true);
+ }
+
+ return array_reverse($migrations);
+ }
+
+ /**
+ * Apply all pending migrations of this hook
+ *
+ * @param ?Connection $conn Use the provided database connection to apply the migrations.
+ * Is only used to elevate database users with insufficient privileges.
+ *
+ * @return bool Whether the migration(s) have been successfully applied
+ */
+ final public function run(Connection $conn = null): bool
+ {
+ if (! $conn) {
+ $conn = $this->getDb();
+ }
+
+ foreach ($this->getMigrations() as $migration) {
+ try {
+ $migration->apply($conn);
+
+ $this->version = $migration->getVersion();
+ unset($this->migrations[$migration->getVersion()]);
+
+ $data = [
+ 'name' => $this->getName(),
+ 'version' => $migration->getVersion()
+ ];
+ AuditHook::logActivity(
+ 'migrations',
+ 'Migrated database schema of {{name}} to version {{version}}',
+ $data
+ );
+
+ $this->storeState($migration->getVersion(), null);
+ } catch (Exception $e) {
+ Logger::error(
+ "Failed to apply %s pending migration version %s \n%s",
+ $this->getName(),
+ $migration->getVersion(),
+ $e
+ );
+ Logger::debug($e->getTraceAsString());
+
+ static::insertFailedEntry(
+ $conn,
+ $migration->getVersion(),
+ $e->getMessage() . PHP_EOL . $e->getTraceAsString()
+ );
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get whether this hook is implemented by a module
+ *
+ * @return bool
+ */
+ public function isModule(): bool
+ {
+ return ClassLoader::classBelongsToModule(static::class);
+ }
+
+ /**
+ * Get the name of the module this hook is implemented by
+ *
+ * @return string
+ */
+ public function getModuleName(): string
+ {
+ if (! $this->isModule()) {
+ return static::DEFAULT_MODULE;
+ }
+
+ return ClassLoader::extractModuleName(static::class);
+ }
+
+ /**
+ * Get the number of pending migrations of this hook
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return count($this->getMigrations());
+ }
+
+ /**
+ * Get a schema version query
+ *
+ * @return Query
+ */
+ abstract protected function getSchemaQuery(): Query;
+
+ protected function load(): void
+ {
+ $upgradeDir = static::MYSQL_UPGRADE_DIR;
+ if ($this->getDb()->getAdapter() instanceof Pgsql) {
+ $upgradeDir = static::PGSQL_UPGRADE_DIR;
+ }
+
+ if (! $this->isModule()) {
+ $path = Icinga::app()->getBaseDir();
+ } else {
+ $path = Module::get($this->getModuleName())->getBaseDir();
+ }
+
+ $descriptions = $this->providedDescriptions();
+ $version = $this->getVersion();
+ /** @var SplFileInfo $file */
+ foreach (new DirectoryIterator($path . DIRECTORY_SEPARATOR . $upgradeDir) as $file) {
+ if (preg_match('/^(v)?([^_]+)(?:_(\w+))?\.sql$/', $file->getFilename(), $m, PREG_UNMATCHED_AS_NULL)) {
+ [$_, $_, $migrateVersion, $description] = array_pad($m, 4, null);
+ /** @var string $migrateVersion */
+ if ($migrateVersion && version_compare($migrateVersion, $version, '>')) {
+ $migration = new DbMigrationStep($migrateVersion, $file->getRealPath());
+ if (isset($descriptions[$migrateVersion])) {
+ $migration->setDescription($descriptions[$migrateVersion]);
+ } elseif ($description) {
+ $migration->setDescription(str_replace('_', ' ', $description));
+ }
+
+ $migration->setLastState($this->loadLastState($migrateVersion));
+
+ $this->migrations[$migrateVersion] = $migration;
+ }
+ }
+ }
+
+ if ($this->migrations) {
+ // Sort all the migrations by their version numbers in ascending order.
+ uksort($this->migrations, function ($a, $b) {
+ return version_compare($a, $b);
+ });
+ }
+ }
+
+ /**
+ * Insert failed migration entry into the database or to the session
+ *
+ * @param Connection $conn
+ * @param string $version
+ * @param string $reason
+ *
+ * @return $this
+ */
+ protected function insertFailedEntry(Connection $conn, string $version, string $reason): self
+ {
+ $schemaQuery = $this->getSchemaQuery()
+ ->filter(Filter::equal('version', $version));
+
+ if (! static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
+ $this->storeState($version, $reason);
+ } else {
+ /** @var Schema $schema */
+ $schema = $schemaQuery->first();
+ if ($schema) {
+ $conn->update($schema->getTableName(), [
+ 'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
+ 'success' => 'n',
+ 'reason' => $reason
+ ], ['id = ?' => $schema->id]);
+ } else {
+ $conn->insert($schemaQuery->getModel()->getTableName(), [
+ 'version' => $version,
+ 'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
+ 'success' => 'n',
+ 'reason' => $reason
+ ]);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Store a failed state message in the session for the given version
+ *
+ * @param string $version
+ * @param ?string $reason
+ *
+ * @return $this
+ */
+ protected function storeState(string $version, ?string $reason): self
+ {
+ $session = Session::getSession()->getNamespace('migrations');
+ /** @var array<string, string> $states */
+ $states = $session->get($this->getModuleName(), []);
+ $states[$version] = $reason;
+
+ $session->set($this->getModuleName(), $states);
+
+ return $this;
+ }
+
+ /**
+ * Load last failed state from database/session for the given version
+ *
+ * @param string $version
+ *
+ * @return ?string
+ */
+ protected function loadLastState(string $version): ?string
+ {
+ $session = Session::getSession()->getNamespace('migrations');
+ /** @var array<string, string> $states */
+ $states = $session->get($this->getModuleName(), []);
+ if (! isset($states[$version])) {
+ $schemaQuery = $this->getSchemaQuery()
+ ->filter(Filter::equal('version', $version))
+ ->filter(Filter::all(Filter::equal('success', 'n')));
+
+ if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) {
+ /** @var Schema $schema */
+ $schema = $schemaQuery->first();
+ if ($schema) {
+ return $schema->reason;
+ }
+ }
+
+ return null;
+ }
+
+ return $states[$version];
+ }
+}
diff --git a/library/Icinga/Application/Hook/GrapherHook.php b/library/Icinga/Application/Hook/GrapherHook.php
new file mode 100644
index 0000000..dfb2135
--- /dev/null
+++ b/library/Icinga/Application/Hook/GrapherHook.php
@@ -0,0 +1,111 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Monitoring\Object\MonitoredObject;
+
+/**
+ * Icinga Web Grapher Hook base class
+ *
+ * Extend this class if you want to integrate your graphing solution nicely into
+ * Icinga Web.
+ */
+abstract class GrapherHook extends WebBaseHook
+{
+ /**
+ * Whether this grapher provides previews
+ *
+ * @var bool
+ */
+ protected $hasPreviews = false;
+
+ /**
+ * Whether this grapher provides tiny previews
+ *
+ * @var bool
+ */
+ protected $hasTinyPreviews = false;
+
+ /**
+ * Constructor must live without arguments right now
+ *
+ * Therefore the constructor is final, we might change our opinion about
+ * this one far day
+ */
+ final public function __construct()
+ {
+ $this->init();
+ }
+
+ /**
+ * Overwrite this function if you want to do some initialization stuff
+ *
+ * @return void
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Whether this grapher provides previews
+ *
+ * @return bool
+ */
+ public function hasPreviews()
+ {
+ return $this->hasPreviews;
+ }
+
+ /**
+ * Whether this grapher provides tiny previews
+ *
+ * @return bool
+ */
+ public function hasTinyPreviews()
+ {
+ return $this->hasTinyPreviews;
+ }
+
+ /**
+ * Whether a graph for the monitoring object exist
+ *
+ * @param MonitoredObject $object
+ *
+ * @return bool
+ */
+ abstract public function has(MonitoredObject $object);
+
+ /**
+ * Get a preview for the given object
+ *
+ * This function must return an empty string if no graph exists.
+ *
+ * @param MonitoredObject $object
+ *
+ * @return string
+ * @throws ProgrammingError
+ *
+ */
+ public function getPreviewHtml(MonitoredObject $object)
+ {
+ throw new ProgrammingError('This hook provide previews but it is not implemented');
+ }
+
+
+ /**
+ * Get a tiny preview for the given object
+ *
+ * This function must return an empty string if no graph exists.
+ *
+ * @param MonitoredObject $object
+ *
+ * @return string
+ * @throws ProgrammingError
+ */
+ public function getTinyPreviewHtml(MonitoredObject $object)
+ {
+ throw new ProgrammingError('This hook provide tiny previews but it is not implemented');
+ }
+}
diff --git a/library/Icinga/Application/Hook/HealthHook.php b/library/Icinga/Application/Hook/HealthHook.php
new file mode 100644
index 0000000..f6420b5
--- /dev/null
+++ b/library/Icinga/Application/Hook/HealthHook.php
@@ -0,0 +1,222 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Exception\IcingaException;
+use ipl\Web\Url;
+use LogicException;
+
+abstract class HealthHook
+{
+ /** @var int */
+ const STATE_OK = 0;
+
+ /** @var int */
+ const STATE_WARNING = 1;
+
+ /** @var int */
+ const STATE_CRITICAL = 2;
+
+ /** @var int */
+ const STATE_UNKNOWN = 3;
+
+ /** @var int The overall state */
+ protected $state;
+
+ /** @var string Message describing the overall state */
+ protected $message;
+
+ /** @var array Available metrics */
+ protected $metrics;
+
+ /** @var Url Url to a graphical representation of the available metrics */
+ protected $url;
+
+ /**
+ * Get overall state
+ *
+ * @return int
+ */
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ /**
+ * Set overall state
+ *
+ * @param int $state
+ *
+ * @return $this
+ */
+ public function setState($state)
+ {
+ $this->state = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the message describing the overall state
+ *
+ * @return string
+ */
+ public function getMessage()
+ {
+ return $this->message;
+ }
+
+ /**
+ * Set the message describing the overall state
+ *
+ * @param string $message
+ *
+ * @return $this
+ */
+ public function setMessage($message)
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ /**
+ * Get available metrics
+ *
+ * @return array
+ */
+ public function getMetrics()
+ {
+ return $this->metrics;
+ }
+
+ /**
+ * Set available metrics
+ *
+ * @param array $metrics
+ *
+ * @return $this
+ */
+ public function setMetrics(array $metrics)
+ {
+ $this->metrics = $metrics;
+
+ return $this;
+ }
+
+ /**
+ * Get the url to a graphical representation of the available metrics
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the url to a graphical representation of the available metrics
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setUrl(Url $url)
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Collect available health data from hooks
+ *
+ * @return ArrayDatasource
+ */
+ final public static function collectHealthData()
+ {
+ $checks = [];
+ foreach (Hook::all('health') as $hook) {
+ /** @var self $hook */
+
+ try {
+ $hook->checkHealth();
+ $url = $hook->getUrl();
+ $state = $hook->getState();
+ $message = $hook->getMessage();
+ $metrics = $hook->getMetrics();
+ } catch (Exception $e) {
+ Logger::error('Failed to check health: %s', $e);
+
+ $state = self::STATE_UNKNOWN;
+ $message = IcingaException::describe($e);
+ $metrics = null;
+ $url = null;
+ }
+
+ $checks[] = (object) [
+ 'module' => $hook->getModuleName(),
+ 'name' => $hook->getName(),
+ 'url' => $url ? $url->getAbsoluteUrl() : null,
+ 'state' => $state,
+ 'message' => $message,
+ 'metrics' => (object) $metrics
+ ];
+ }
+
+ return (new ArrayDatasource($checks))
+ ->setKeyColumn('name');
+ }
+
+ /**
+ * Get the name of the hook
+ *
+ * Only used in API responses to differentiate it from other hooks of the same module.
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ $classPath = get_class($this);
+ $parts = explode('\\', $classPath);
+ $className = array_pop($parts);
+
+ if (substr($className, -4) === 'Hook') {
+ $className = substr($className, 1, -4);
+ }
+
+ return strtolower($className[0]) . substr($className, 1);
+ }
+
+ /**
+ * Get the name of the module providing this hook
+ *
+ * @return string
+ *
+ * @throws LogicException
+ */
+ public function getModuleName()
+ {
+ $classPath = get_class($this);
+ if (substr($classPath, 0, 14) !== 'Icinga\\Module\\') {
+ throw new LogicException('Not a module hook');
+ }
+
+ $withoutPrefix = substr($classPath, 14);
+ return strtolower(substr($withoutPrefix, 0, strpos($withoutPrefix, '\\')));
+ }
+
+ /**
+ * Check health
+ *
+ * Implement this method and set the overall state, message, url and metrics.
+ *
+ * @return void
+ */
+ abstract public function checkHealth();
+}
diff --git a/library/Icinga/Application/Hook/PdfexportHook.php b/library/Icinga/Application/Hook/PdfexportHook.php
new file mode 100644
index 0000000..36e9f51
--- /dev/null
+++ b/library/Icinga/Application/Hook/PdfexportHook.php
@@ -0,0 +1,25 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+/**
+ * Base class for the PDF Export Hook
+ */
+abstract class PdfexportHook
+{
+ /**
+ * Get whether PDF export is supported
+ *
+ * @return bool
+ */
+ abstract public function isSupported();
+
+ /**
+ * Render the specified HTML to PDF and stream it to the client
+ *
+ * @param string $html The HTML to render to PDF
+ * @param string $filename The filename for the generated PDF
+ */
+ abstract public function streamPdfFromHtml($html, $filename);
+}
diff --git a/library/Icinga/Application/Hook/ThemeLoaderHook.php b/library/Icinga/Application/Hook/ThemeLoaderHook.php
new file mode 100644
index 0000000..5320dd5
--- /dev/null
+++ b/library/Icinga/Application/Hook/ThemeLoaderHook.php
@@ -0,0 +1,22 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+/**
+ * Provide an implementation of this hook to dynamically provide themes.
+ * Note that only the first registered hook is utilized. Also note that
+ * for ordinary themes this hook is not required. Place such in your
+ * module's theme path: <module-path>/public/css/themes
+ */
+abstract class ThemeLoaderHook
+{
+ /**
+ * Get the path for the given theme
+ *
+ * @param ?string $theme
+ *
+ * @return ?string The path or NULL if the theme is unknown
+ */
+ abstract public function getThemeFile(?string $theme): ?string;
+}
diff --git a/library/Icinga/Application/Hook/Ticket/TicketPattern.php b/library/Icinga/Application/Hook/Ticket/TicketPattern.php
new file mode 100644
index 0000000..e37fcc1
--- /dev/null
+++ b/library/Icinga/Application/Hook/Ticket/TicketPattern.php
@@ -0,0 +1,140 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook\Ticket;
+
+use ArrayAccess;
+
+/**
+ * A ticket pattern
+ *
+ * This class should be used by modules which provide implementations for the Web 2 ticket hook.
+ * Have a look at the GenericTTS module for a possible use case.
+ */
+class TicketPattern implements ArrayAccess
+{
+ /**
+ * The result of a performed ticket match
+ *
+ * @var array
+ */
+ protected $match = array();
+
+ /**
+ * The name of the TTS integration
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The ticket pattern
+ *
+ * @var string
+ */
+ protected $pattern;
+
+ public function offsetExists($offset): bool
+ {
+ return isset($this->match[$offset]);
+ }
+
+ public function offsetGet($offset): ?string
+ {
+ return array_key_exists($offset, $this->match) ? $this->match[$offset] : null;
+ }
+
+ public function offsetSet($offset, $value): void
+ {
+ if ($offset === null) {
+ $this->match[] = $value;
+ } else {
+ $this->match[$offset] = $value;
+ }
+ }
+
+ public function offsetUnset($offset): void
+ {
+ unset($this->match[$offset]);
+ }
+
+
+ /**
+ * Get the result of a performed ticket match
+ *
+ * @return array
+ */
+ public function getMatch()
+ {
+ return $this->match;
+ }
+
+ /**
+ * Set the result of a performed ticket match
+ *
+ * @param array $match
+ *
+ * @return $this
+ */
+ public function setMatch(array $match)
+ {
+ $this->match = $match;
+ return $this;
+ }
+
+ /**
+ * Get the name of the TTS integration
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of the TTS integration
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Get the ticket pattern
+ *
+ * @return string
+ */
+ public function getPattern()
+ {
+ return $this->pattern;
+ }
+
+ /**
+ * Set the ticket pattern
+ *
+ * @param string $pattern
+ *
+ * @return $this
+ */
+ public function setPattern($pattern)
+ {
+ $this->pattern = $pattern;
+ return $this;
+ }
+
+ /**
+ * Whether the integration is properly configured, i.e. the pattern and the URL are not empty
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return ! empty($this->pattern);
+ }
+}
diff --git a/library/Icinga/Application/Hook/TicketHook.php b/library/Icinga/Application/Hook/TicketHook.php
new file mode 100644
index 0000000..ceb3738
--- /dev/null
+++ b/library/Icinga/Application/Hook/TicketHook.php
@@ -0,0 +1,210 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use ArrayIterator;
+use ErrorException;
+use Exception;
+use Icinga\Application\Hook\Ticket\TicketPattern;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+
+/**
+ * Base class for ticket hooks
+ *
+ * Extend this class if you want to integrate your ticketing solution into Icinga Web 2.
+ */
+abstract class TicketHook
+{
+ /**
+ * Last error, if any
+ *
+ * @var string|null
+ */
+ protected $lastError;
+
+ /**
+ * Create a new ticket hook
+ *
+ * @see init() For hook initialization.
+ */
+ final public function __construct()
+ {
+ $this->init();
+ }
+
+ /**
+ * Overwrite this function for hook initialization, e.g. loading the hook's config
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Create a link for each matched element in the subject text
+ *
+ * @param array|TicketPattern $match Matched element according to {@link getPattern()}
+ *
+ * @return string Replacement string
+ */
+ abstract public function createLink($match);
+
+ /**
+ * Get the pattern(s) to search for
+ *
+ * Return an array of TicketPattern instances here to support multiple TTS integrations.
+ *
+ * @return string|TicketPattern[]
+ */
+ abstract public function getPattern();
+
+ /**
+ * Apply ticket patterns to the given text
+ *
+ * @param string $text
+ * @param TicketPattern[] $ticketPatterns
+ *
+ * @return string
+ */
+ private function applyTicketPatterns($text, array $ticketPatterns)
+ {
+ $out = '';
+ $start = 0;
+
+ $iterator = new ArrayIterator($ticketPatterns);
+ $iterator->rewind();
+
+ while ($iterator->valid()) {
+ $ticketPattern = $iterator->current();
+
+ try {
+ preg_match($ticketPattern->getPattern(), $text, $match, PREG_OFFSET_CAPTURE, $start);
+ } catch (ErrorException $e) {
+ $this->fail('Can\'t create ticket links: Pattern is invalid: %s', IcingaException::describe($e));
+ $iterator->next();
+ continue;
+ }
+
+ if (empty($match)) {
+ $iterator->next();
+ continue;
+ }
+
+ // Remove preg_offset from match for the ticket pattern
+ $carry = array();
+ array_walk($match, function ($value, $key) use (&$carry) {
+ $carry[$key] = $value[0];
+ }, $carry);
+ $ticketPattern->setMatch($carry);
+
+ $offsetLeft = $match[0][1];
+ $matchLength = strlen($match[0][0]);
+
+ $out .= substr($text, $start, $offsetLeft - $start);
+
+ try {
+ $out .= $this->createLink($ticketPattern);
+ } catch (Exception $e) {
+ $this->fail('Can\'t create ticket links: %s', IcingaException::describe($e));
+ return $text;
+ }
+
+ $start = $offsetLeft + $matchLength;
+ }
+
+ $out .= substr($text, $start);
+
+ return $out;
+ }
+
+ /**
+ * Helper function to create a TicketPattern instance
+ *
+ * @param string $name Name of the TTS integration
+ * @param string $pattern Ticket pattern
+ *
+ * @return TicketPattern
+ */
+ protected function createTicketPattern($name, $pattern)
+ {
+ $ticketPattern = new TicketPattern();
+ $ticketPattern
+ ->setName($name)
+ ->setPattern($pattern);
+ return $ticketPattern;
+ }
+
+ /**
+ * Set the hook as failed w/ the given message
+ *
+ * @param string $message Error message or error format string
+ * @param mixed ...$arg Format string argument
+ */
+ private function fail($message)
+ {
+ $args = array_slice(func_get_args(), 1);
+ $lastError = vsprintf($message, $args);
+ Logger::debug($lastError);
+ $this->lastError = $lastError;
+ }
+
+ /**
+ * Get the last error, if any
+ *
+ * @return string|null
+ */
+ public function getLastError()
+ {
+ return $this->lastError;
+ }
+
+ /**
+ * Create links w/ {@link createLink()} in the given text that matches to the subject from {@link getPattern()}
+ *
+ * In case of errors a debug message is recorded to the log and any subsequent call to {@link createLinks()} will
+ * be a no-op.
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ final public function createLinks($text)
+ {
+ if ($this->lastError !== null) {
+ return $text;
+ }
+
+ try {
+ $pattern = $this->getPattern();
+ } catch (Exception $e) {
+ $this->fail('Can\'t create ticket links: Retrieving the pattern failed: %s', IcingaException::describe($e));
+ return $text;
+ }
+
+ if (empty($pattern)) {
+ $this->fail('Can\'t create ticket links: Pattern is empty');
+ return $text;
+ }
+
+ if (is_array($pattern)) {
+ $text = $this->applyTicketPatterns($text, $pattern);
+ } else {
+ try {
+ $text = preg_replace_callback(
+ $pattern,
+ array($this, 'createLink'),
+ $text
+ );
+ } catch (ErrorException $e) {
+ $this->fail('Can\'t create ticket links: Pattern is invalid: %s', IcingaException::describe($e));
+ return $text;
+ } catch (Exception $e) {
+ $this->fail('Can\'t create ticket links: %s', IcingaException::describe($e));
+ return $text;
+ }
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icinga/Application/Hook/WebBaseHook.php b/library/Icinga/Application/Hook/WebBaseHook.php
new file mode 100644
index 0000000..09e8f4f
--- /dev/null
+++ b/library/Icinga/Application/Hook/WebBaseHook.php
@@ -0,0 +1,54 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Zend_Controller_Action_HelperBroker;
+use Zend_View;
+
+/**
+ * Base class for web hooks
+ *
+ * The class provides access to the view
+ */
+class WebBaseHook
+{
+ /**
+ * View instance
+ *
+ * @var Zend_View
+ */
+ private $view;
+
+ /**
+ * Set the view instance
+ *
+ * @param Zend_View $view
+ *
+ * @return $this
+ */
+ public function setView(Zend_View $view)
+ {
+ $this->view = $view;
+
+ return $this;
+ }
+
+ /**
+ * Get the view instance
+ *
+ * @return Zend_View
+ */
+ public function getView()
+ {
+ if ($this->view === null) {
+ $viewRenderer = Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer');
+ if ($viewRenderer->view === null) {
+ $viewRenderer->initView();
+ }
+ $this->view = $viewRenderer->view;
+ }
+
+ return $this->view;
+ }
+}
diff --git a/library/Icinga/Application/Icinga.php b/library/Icinga/Application/Icinga.php
new file mode 100644
index 0000000..ba54015
--- /dev/null
+++ b/library/Icinga/Application/Icinga.php
@@ -0,0 +1,49 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Icinga application container
+ */
+class Icinga
+{
+ /**
+ * @var ApplicationBootstrap
+ */
+ private static $app;
+
+ /**
+ * Getter for an application environment
+ *
+ * @return ApplicationBootstrap|Web
+ * @throws ProgrammingError
+ */
+ public static function app()
+ {
+ if (self::$app == null) {
+ throw new ProgrammingError('Icinga has never been started');
+ }
+
+ return self::$app;
+ }
+
+ /**
+ * Setter for an application environment
+ *
+ * @param ApplicationBootstrap $app
+ * @param bool $overwrite
+ *
+ * @throws ProgrammingError
+ */
+ public static function setApp(ApplicationBootstrap $app, $overwrite = false)
+ {
+ if (self::$app !== null && !$overwrite) {
+ throw new ProgrammingError('Cannot start Icinga twice');
+ }
+
+ self::$app = $app;
+ }
+}
diff --git a/library/Icinga/Application/LegacyWeb.php b/library/Icinga/Application/LegacyWeb.php
new file mode 100644
index 0000000..21181f7
--- /dev/null
+++ b/library/Icinga/Application/LegacyWeb.php
@@ -0,0 +1,33 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+require_once dirname(__FILE__) . '/Web.php';
+
+use Exception;
+use Icinga\Exception\ProgrammingError;
+
+class LegacyWeb extends Web
+{
+ // IcingaWeb 1.x base dir
+ protected $legacyBasedir;
+
+ protected function bootstrap()
+ {
+ parent::bootstrap();
+ throw new ProgrammingError('Not yet');
+ // $this->setupIcingaLegacyWrapper();
+ }
+
+ /**
+ * Get the Icinga-Web 1.x base path
+ *
+ * @throws Exception
+ * @return self
+ */
+ public function getLecacyBasedir()
+ {
+ return $this->legacyBasedir;
+ }
+}
diff --git a/library/Icinga/Application/Libraries.php b/library/Icinga/Application/Libraries.php
new file mode 100644
index 0000000..8e4a79d
--- /dev/null
+++ b/library/Icinga/Application/Libraries.php
@@ -0,0 +1,91 @@
+<?php
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application;
+
+use ArrayIterator;
+use IteratorAggregate;
+use Icinga\Application\Libraries\Library;
+use Traversable;
+
+class Libraries implements IteratorAggregate
+{
+ /** @var Library[] */
+ protected $libraries = [];
+
+ /**
+ * Iterate over registered libraries
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->libraries);
+ }
+
+ /**
+ * Register a library from the given path
+ *
+ * @param string $path
+ *
+ * @return Library The registered library
+ */
+ public function registerPath($path)
+ {
+ $library = new Library($path);
+ $this->libraries[] = $library;
+
+ return $library;
+ }
+
+ /**
+ * Check if a library with the given name has been registered
+ *
+ * Passing a version constraint also verifies that the library's version matches.
+ *
+ * @param string $name
+ * @param string $version
+ *
+ * @return bool
+ */
+ public function has($name, $version = null)
+ {
+ $library = $this->get($name);
+ if ($library === null) {
+ return false;
+ } elseif ($version === null || $version === true) {
+ return true;
+ }
+
+ $operator = '=';
+ if (preg_match('/^([<>=]{1,2})\s*v?((?:[\d.]+)(?:\D+)?)$/', $version, $match)) {
+ $operator = $match[1];
+ $version = $match[2];
+ }
+
+ return version_compare($library->getVersion(), $version, $operator);
+ }
+
+ /**
+ * Get a library by name
+ *
+ * @param string $name
+ *
+ * @return Library|null
+ */
+ public function get($name)
+ {
+ $candidate = null;
+ foreach ($this->libraries as $library) {
+ $libraryName = $library->getName();
+ if ($libraryName === $name) {
+ return $library;
+ } elseif (strpos($libraryName, '/') !== false && explode('/', $libraryName)[1] === $name) {
+ // Also return libs which only partially match
+ $candidate = $library;
+ }
+ }
+
+ return $candidate;
+ }
+}
diff --git a/library/Icinga/Application/Libraries/Library.php b/library/Icinga/Application/Libraries/Library.php
new file mode 100644
index 0000000..63e50b2
--- /dev/null
+++ b/library/Icinga/Application/Libraries/Library.php
@@ -0,0 +1,259 @@
+<?php
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Libraries;
+
+use CallbackFilterIterator;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Util\Json;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+
+class Library
+{
+ /** @var string */
+ protected $path;
+
+ /** @var string */
+ protected $jsAssetPath;
+
+ /** @var string */
+ protected $cssAssetPath;
+
+ /** @var string */
+ protected $staticAssetPath;
+
+ /** @var string */
+ protected $version;
+
+ /** @var array */
+ protected $metaData;
+
+ /** @var array */
+ protected $assets;
+
+ /**
+ * Create a new Library
+ *
+ * @param string $path
+ */
+ public function __construct($path)
+ {
+ $this->path = $path;
+ }
+
+ /**
+ * Get this library's path
+ *
+ * @return string
+ */
+ public function getPath()
+ {
+ return $this->path;
+ }
+
+ /**
+ * Get path of this library's JS assets
+ *
+ * @return string
+ */
+ public function getJsAssetPath()
+ {
+ $this->assets();
+ return $this->jsAssetPath;
+ }
+
+ /**
+ * Get path of this library's CSS assets
+ *
+ * @return string
+ */
+ public function getCssAssetPath()
+ {
+ $this->assets();
+ return $this->cssAssetPath;
+ }
+
+ /**
+ * Get path of this library's static assets
+ *
+ * @return string
+ */
+ public function getStaticAssetPath()
+ {
+ $this->assets();
+ return $this->staticAssetPath;
+ }
+
+ /**
+ * Get this library's name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->metaData()['name'];
+ }
+
+ /**
+ * Get this library's version
+ *
+ * @return string
+ */
+ public function getVersion()
+ {
+ if ($this->version === null) {
+ if (isset($this->metaData()['version'])) {
+ $this->version = trim(ltrim($this->metaData()['version'], 'v'));
+ } else {
+ $versionFile = $this->path . DIRECTORY_SEPARATOR . 'VERSION';
+ if (file_exists($versionFile)) {
+ $this->version = trim(ltrim(file_get_contents($versionFile), 'v'));
+ } else {
+ $this->version = '';
+ }
+ }
+ }
+
+ return $this->version;
+ }
+
+ /**
+ * Check whether the given package is required
+ *
+ * @param string $vendor The vendor of the project
+ * @param string $project The project's name
+ *
+ * @return bool
+ */
+ public function isRequired($vendor, $project)
+ {
+ // Ensure the parts are lowercase and separated by dashes, not capital letters
+ $project = strtolower(join('-', preg_split('/\w(?=[A-Z])/', $project)));
+
+ return isset($this->metaData()['require'][strtolower($vendor) . '/' . $project]);
+ }
+
+ /**
+ * Get this library's JS assets
+ *
+ * @return string[] Asset paths
+ */
+ public function getJsAssets()
+ {
+ return $this->assets()['js'];
+ }
+
+ /**
+ * Get this library's CSS assets
+ *
+ * @return string[] Asset paths
+ */
+ public function getCssAssets()
+ {
+ return $this->assets()['css'];
+ }
+
+ /**
+ * Get this library's static assets
+ *
+ * @return string[] Asset paths
+ */
+ public function getStaticAssets()
+ {
+ return $this->assets()['static'];
+ }
+
+ /**
+ * Register this library's autoloader
+ *
+ * @return void
+ */
+ public function registerAutoloader()
+ {
+ $autoloaderPath = join(DIRECTORY_SEPARATOR, [$this->path, 'vendor', 'autoload.php']);
+ if (file_exists($autoloaderPath)) {
+ require_once $autoloaderPath;
+ }
+ }
+
+ /**
+ * Parse and return this library's metadata
+ *
+ * @return array
+ *
+ * @throws ConfigurationError
+ * @throws JsonDecodeException
+ */
+ protected function metaData()
+ {
+ if ($this->metaData === null) {
+ $metaData = @file_get_contents($this->path . DIRECTORY_SEPARATOR . 'composer.json');
+ if ($metaData === false) {
+ throw new ConfigurationError('Library at "%s" is not a composerized project', $this->path);
+ }
+
+ $this->metaData = Json::decode($metaData, true);
+ }
+
+ return $this->metaData;
+ }
+
+ /**
+ * Register and return this library's assets
+ *
+ * @return array
+ */
+ protected function assets()
+ {
+ if ($this->assets !== null) {
+ return $this->assets;
+ }
+
+ $listAssets = function ($type) {
+ $dir = join(DIRECTORY_SEPARATOR, [$this->path, 'asset', $type]);
+ if (! is_dir($dir)) {
+ return [];
+ }
+
+ $this->{$type . 'AssetPath'} = $dir;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
+ $dir,
+ RecursiveDirectoryIterator::CURRENT_AS_FILEINFO | RecursiveDirectoryIterator::SKIP_DOTS
+ ));
+ if ($type === 'static') {
+ return $iterator;
+ }
+
+ return new CallbackFilterIterator(
+ $iterator,
+ function ($path) use ($type) {
+ if ($type === 'js' && $path->getExtension() === 'js') {
+ return substr($path->getPathname(), -5 - strlen($type)) !== ".min.$type";
+ } elseif ($type === 'css'
+ && ($path->getExtension() === 'css' || $path->getExtension() === 'less')
+ ) {
+ return substr($path->getPathname(), -5 - strlen($type)) !== ".min.$type";
+ }
+
+ return false;
+ }
+ );
+ };
+
+ $this->assets = [];
+
+ $jsAssets = $listAssets('js');
+ $this->assets['js'] = is_array($jsAssets) ? $jsAssets : iterator_to_array($jsAssets);
+
+ $cssAssets = $listAssets('css');
+ $this->assets['css'] = is_array($cssAssets) ? $cssAssets : iterator_to_array($cssAssets);
+
+ $staticAssets = $listAssets('static');
+ $this->assets['static'] = is_array($staticAssets) ? $staticAssets : iterator_to_array($staticAssets);
+
+ return $this->assets;
+ }
+}
diff --git a/library/Icinga/Application/Logger.php b/library/Icinga/Application/Logger.php
new file mode 100644
index 0000000..937029c
--- /dev/null
+++ b/library/Icinga/Application/Logger.php
@@ -0,0 +1,349 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Application\Logger\Writer\FileWriter;
+use Icinga\Application\Logger\Writer\SyslogWriter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\IcingaException;
+use Icinga\Util\Json;
+use Throwable;
+
+/**
+ * Logger
+ */
+class Logger
+{
+ /**
+ * Debug message
+ */
+ const DEBUG = 1;
+
+ /**
+ * Informational message
+ */
+ const INFO = 2;
+
+ /**
+ * Warning message
+ */
+ const WARNING = 4;
+
+ /**
+ * Error message
+ */
+ const ERROR = 8;
+
+ /**
+ * Log levels
+ *
+ * @var array
+ */
+ public static $levels = array(
+ Logger::DEBUG => 'DEBUG',
+ Logger::INFO => 'INFO',
+ Logger::WARNING => 'WARNING',
+ Logger::ERROR => 'ERROR'
+ );
+
+ /**
+ * This logger's instance
+ *
+ * @var static
+ */
+ protected static $instance;
+
+ /**
+ * Log writer
+ *
+ * @var \Icinga\Application\Logger\LogWriter
+ */
+ protected $writer;
+
+ /**
+ * Maximum level to emit
+ *
+ * @var int
+ */
+ protected $level;
+
+ /**
+ * Error messages to be displayed prior to any other log message
+ *
+ * @var array
+ */
+ protected $configErrors = array();
+
+ /**
+ * Create a new logger object
+ *
+ * @param ConfigObject $config
+ *
+ * @throws ConfigurationError If the logging configuration directive 'log' is missing or if the logging level is
+ * not defined
+ */
+ public function __construct(ConfigObject $config)
+ {
+ if ($config->log === null) {
+ throw new ConfigurationError('Required logging configuration directive \'log\' missing');
+ }
+
+ $this->setLevel($config->get('level', static::ERROR));
+
+ if (strtolower($config->get('log', 'syslog')) !== 'none') {
+ $this->writer = $this->createWriter($config);
+ }
+ }
+
+ /**
+ * Set the logging level to use
+ *
+ * @param mixed $level
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError In case the given level is invalid
+ */
+ public function setLevel($level)
+ {
+ if (is_numeric($level)) {
+ $level = (int) $level;
+ if (! isset(static::$levels[$level])) {
+ throw new ConfigurationError(
+ 'Can\'t set logging level %d. Logging level is invalid. Use one of %s or one of the'
+ . ' Logger\'s constants.',
+ $level,
+ implode(', ', array_keys(static::$levels))
+ );
+ }
+
+ $this->level = $level;
+ } else {
+ $level = strtoupper($level);
+ $levels = array_flip(static::$levels);
+ if (! isset($levels[$level])) {
+ throw new ConfigurationError(
+ 'Can\'t set logging level "%s". Logging level is invalid. Use one of %s.',
+ $level,
+ implode(', ', array_keys($levels))
+ );
+ }
+
+ $this->level = $levels[$level];
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the logging level being used
+ *
+ * @return int
+ */
+ public function getLevel()
+ {
+ return $this->level;
+ }
+
+ /**
+ * Register the given message as config error
+ *
+ * Config errors are logged every time a log message is being logged.
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ *
+ * @return $this
+ */
+ public function registerConfigError()
+ {
+ if (func_num_args() > 0) {
+ $this->configErrors[] = static::formatMessage(func_get_args());
+ }
+
+ return $this;
+ }
+
+ /**
+ * Create a new logger object
+ *
+ * @param ConfigObject $config
+ *
+ * @return static
+ */
+ public static function create(ConfigObject $config)
+ {
+ static::$instance = new static($config);
+ return static::$instance;
+ }
+
+ /**
+ * Create a log writer
+ *
+ * @param ConfigObject $config The configuration to initialize the writer with
+ *
+ * @return \Icinga\Application\Logger\LogWriter The requested log writer
+ * @throws ConfigurationError If the requested writer cannot be found
+ */
+ protected function createWriter(ConfigObject $config)
+ {
+ $class = 'Icinga\\Application\\Logger\\Writer\\' . ucfirst(strtolower($config->log)) . 'Writer';
+ if (! class_exists($class)) {
+ throw new ConfigurationError(
+ 'Cannot find log writer of type "%s"',
+ $config->log
+ );
+ }
+ return new $class($config);
+ }
+
+ /**
+ * Log a message
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ */
+ public function log($level, $message)
+ {
+ if ($this->writer !== null && $this->level <= $level) {
+ foreach ($this->configErrors as $error_message) {
+ $this->writer->log(static::ERROR, $error_message);
+ }
+
+ $this->writer->log($level, $message);
+ }
+ }
+
+ /**
+ * Return a string representation of the passed arguments
+ *
+ * This method provides three different processing techniques:
+ * - If the only passed argument is a string it is returned unchanged
+ * - If the only passed argument is an exception it is formatted as follows:
+ * <name> in <file>:<line> with message: <message>[ <- <name> ...]
+ * - If multiple arguments are passed the first is interpreted as format-string
+ * that gets substituted with the remaining ones which can be of any type
+ *
+ * @param array $arguments The arguments to format
+ *
+ * @return string The formatted result
+ */
+ protected static function formatMessage(array $arguments)
+ {
+ if (count($arguments) === 1) {
+ $message = $arguments[0];
+
+ if ($message instanceof Throwable) {
+ $messages = array();
+ $error = $message;
+ do {
+ $messages[] = IcingaException::describe($error);
+ } while ($error = $error->getPrevious());
+ $message = implode(' <- ', $messages);
+ }
+
+ return $message;
+ }
+
+ return vsprintf(
+ array_shift($arguments),
+ array_map(
+ function ($a) {
+ return is_string($a) ? $a : ($a instanceof Throwable
+ ? IcingaException::describe($a)
+ : Json::encode($a));
+ },
+ $arguments
+ )
+ );
+ }
+
+ /**
+ * Log a message with severity ERROR
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function error()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::ERROR, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Log a message with severity WARNING
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function warning()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::WARNING, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Log a message with severity INFO
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function info()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::INFO, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Log a message with severity DEBUG
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function debug()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::DEBUG, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Get the log writer to use
+ *
+ * @return \Icinga\Application\Logger\LogWriter
+ */
+ public function getWriter()
+ {
+ return $this->writer;
+ }
+
+ /**
+ * Is the logger writing to Syslog?
+ *
+ * @return bool
+ */
+ public static function writesToSyslog()
+ {
+ return static::$instance && static::$instance->getWriter() instanceof SyslogWriter;
+ }
+
+ /**
+ * Is the logger writing to a file?
+ *
+ * @return bool
+ */
+ public static function writesToFile()
+ {
+ return static::$instance && static::$instance->getWriter() instanceof FileWriter;
+ }
+
+ /**
+ * Get this' instance
+ *
+ * @return static
+ */
+ public static function getInstance()
+ {
+ return static::$instance;
+ }
+}
diff --git a/library/Icinga/Application/Logger/LogWriter.php b/library/Icinga/Application/Logger/LogWriter.php
new file mode 100644
index 0000000..019bdad
--- /dev/null
+++ b/library/Icinga/Application/Logger/LogWriter.php
@@ -0,0 +1,30 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger;
+
+use Icinga\Data\ConfigObject;
+
+/**
+ * Abstract class for writers that write messages to a log
+ */
+abstract class LogWriter
+{
+ /**
+ * @var ConfigObject
+ */
+ protected $config;
+
+ /**
+ * Create a new log writer initialized with the given configuration
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->config = $config;
+ }
+
+ /**
+ * Log a message with the given severity
+ */
+ abstract public function log($severity, $message);
+}
diff --git a/library/Icinga/Application/Logger/Writer/FileWriter.php b/library/Icinga/Application/Logger/Writer/FileWriter.php
new file mode 100644
index 0000000..6b4ed54
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/FileWriter.php
@@ -0,0 +1,80 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Exception;
+use Icinga\Data\ConfigObject;
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Util\File;
+
+/**
+ * Log to a file
+ */
+class FileWriter extends LogWriter
+{
+ /**
+ * Path to the file
+ *
+ * @var string
+ */
+ protected $file;
+
+ /**
+ * Create a new file log writer
+ *
+ * @param ConfigObject $config
+ *
+ * @throws ConfigurationError If the configuration directive 'file' is missing or if the path to 'file' does
+ * not exist or if writing to 'file' is not possible
+ */
+ public function __construct(ConfigObject $config)
+ {
+ if ($config->file === null) {
+ throw new ConfigurationError('Required logging configuration directive \'file\' missing');
+ }
+ $this->file = $config->file;
+
+ if (substr($this->file, 0, 6) !== 'php://' && ! file_exists(dirname($this->file))) {
+ throw new ConfigurationError(
+ 'Log path "%s" does not exist',
+ dirname($this->file)
+ );
+ }
+
+ try {
+ $this->write(''); // Avoid to handle such errors on every write access
+ } catch (Exception $e) {
+ throw new ConfigurationError(
+ 'Cannot write to log file "%s" (%s)',
+ $this->file,
+ $e->getMessage()
+ );
+ }
+ }
+
+ /**
+ * Log a message
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ */
+ public function log($level, $message)
+ {
+ $this->write(date('c') . ' - ' . Logger::$levels[$level] . ' - ' . $message . PHP_EOL);
+ }
+
+ /**
+ * Write a message to the log
+ *
+ * @param string $message
+ */
+ protected function write($message)
+ {
+ $file = new File($this->file, 'a');
+ $file->fwrite($message);
+ $file->fflush();
+ }
+}
diff --git a/library/Icinga/Application/Logger/Writer/PhpWriter.php b/library/Icinga/Application/Logger/Writer/PhpWriter.php
new file mode 100644
index 0000000..dedb2bd
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/PhpWriter.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\NotWritableError;
+
+/**
+ * Log to the webserver log, a file or syslog
+ *
+ * @see https://secure.php.net/manual/en/errorfunc.configuration.php#ini.error-log
+ */
+class PhpWriter extends LogWriter
+{
+ /**
+ * Prefix to prepend to each message
+ *
+ * @var string
+ */
+ protected $ident;
+
+ public function __construct(ConfigObject $config)
+ {
+ parent::__construct($config);
+ $this->ident = $config->get('application', 'icingaweb2');
+ }
+
+ public function log($severity, $message)
+ {
+ if (ini_get('error_log') === 'syslog') {
+ $message = str_replace("\n", ' ', $message);
+ }
+
+ error_log($this->ident . ': ' . Logger::$levels[$severity] . ' - ' . $message);
+ }
+}
diff --git a/library/Icinga/Application/Logger/Writer/StderrWriter.php b/library/Icinga/Application/Logger/Writer/StderrWriter.php
new file mode 100644
index 0000000..7df4278
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/StderrWriter.php
@@ -0,0 +1,62 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Icinga\Cli\Screen;
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+
+/**
+ * Class to write log messages to STDERR
+ */
+class StderrWriter extends LogWriter
+{
+ /**
+ * The current Screen in use
+ *
+ * @var Screen
+ */
+ protected $screen;
+
+ /**
+ * Return the current Screen
+ *
+ * @return Screen
+ */
+ protected function screen()
+ {
+ if ($this->screen === null) {
+ $this->screen = Screen::instance(STDERR);
+ }
+
+ return $this->screen;
+ }
+
+ /**
+ * Log a message with the given severity
+ *
+ * @param int $severity The severity to use
+ * @param string $message The message to log
+ */
+ public function log($severity, $message)
+ {
+ $color = null;
+ switch ($severity) {
+ case Logger::ERROR:
+ $color = 'red';
+ break;
+ case Logger::WARNING:
+ $color = 'yellow';
+ break;
+ case Logger::INFO:
+ $color = 'green';
+ break;
+ case Logger::DEBUG:
+ $color = 'blue';
+ break;
+ }
+
+ file_put_contents('php://stderr', $this->screen()->colorize($message, $color) . "\n");
+ }
+}
diff --git a/library/Icinga/Application/Logger/Writer/StdoutWriter.php b/library/Icinga/Application/Logger/Writer/StdoutWriter.php
new file mode 100644
index 0000000..a6f43e5
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/StdoutWriter.php
@@ -0,0 +1,13 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+/**
+ * Deprecated, compat only.
+ *
+ * Use Icinga\Application\Logger\Writer\StderrWriter instead.
+ */
+class StdoutWriter extends StderrWriter
+{
+}
diff --git a/library/Icinga/Application/Logger/Writer/SyslogWriter.php b/library/Icinga/Application/Logger/Writer/SyslogWriter.php
new file mode 100644
index 0000000..93efc2a
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/SyslogWriter.php
@@ -0,0 +1,90 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * Log to the syslog service
+ */
+class SyslogWriter extends LogWriter
+{
+ /**
+ * Syslog facility
+ *
+ * @var int
+ */
+ protected $facility;
+
+ /**
+ * Prefix to prepend to each message
+ *
+ * @var string
+ */
+ protected $ident;
+
+ /**
+ * Known syslog facilities
+ *
+ * @var array
+ */
+ public static $facilities = array(
+ 'user' => LOG_USER,
+ 'local0' => LOG_LOCAL0,
+ 'local1' => LOG_LOCAL1,
+ 'local2' => LOG_LOCAL2,
+ 'local3' => LOG_LOCAL3,
+ 'local4' => LOG_LOCAL4,
+ 'local5' => LOG_LOCAL5,
+ 'local6' => LOG_LOCAL6,
+ 'local7' => LOG_LOCAL7
+ );
+
+ /**
+ * Log level to syslog severity map
+ *
+ * @var array
+ */
+ public static $severityMap = array(
+ Logger::ERROR => LOG_ERR,
+ Logger::WARNING => LOG_WARNING,
+ Logger::INFO => LOG_INFO,
+ Logger::DEBUG => LOG_DEBUG
+ );
+
+ /**
+ * Create a new syslog log writer
+ *
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->ident = $config->get('application', 'icingaweb2');
+
+ $configuredFacility = $config->get('facility', 'user');
+ if (! isset(static::$facilities[$configuredFacility])) {
+ throw new ConfigurationError(
+ 'Invalid logging facility: "%s" (expected one of: %s)',
+ $configuredFacility,
+ implode(', ', array_keys(static::$facilities))
+ );
+ }
+ $this->facility = static::$facilities[$configuredFacility];
+ }
+
+ /**
+ * Log a message
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ */
+ public function log($level, $message)
+ {
+ openlog($this->ident, LOG_PID, $this->facility);
+ syslog(static::$severityMap[$level], str_replace("\n", ' ', $message));
+ }
+}
diff --git a/library/Icinga/Application/MigrationManager.php b/library/Icinga/Application/MigrationManager.php
new file mode 100644
index 0000000..9d32896
--- /dev/null
+++ b/library/Icinga/Application/MigrationManager.php
@@ -0,0 +1,417 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Countable;
+use Generator;
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Setup\Utils\DbTool;
+use Icinga\Module\Setup\WebWizard;
+use ipl\I18n\Translation;
+use ipl\Sql;
+use ReflectionClass;
+
+/**
+ * Migration manager allows you to manage all pending migrations in a structured way.
+ */
+final class MigrationManager implements Countable
+{
+ use Translation;
+
+ /** @var array<string, DbMigrationHook> All pending migration hooks */
+ protected $pendingMigrations;
+
+ /** @var MigrationManager */
+ private static $instance;
+
+ private function __construct()
+ {
+ }
+
+ /**
+ * Get the instance of this manager
+ *
+ * @return $this
+ */
+ public static function instance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get all pending migrations
+ *
+ * @return array<string, DbMigrationHook>
+ */
+ public function getPendingMigrations(): array
+ {
+ if ($this->pendingMigrations === null) {
+ $this->load();
+ }
+
+ return $this->pendingMigrations;
+ }
+
+ /**
+ * Get whether there are any pending migrations
+ *
+ * @return bool
+ */
+ public function hasPendingMigrations(): bool
+ {
+ return $this->count() > 0;
+ }
+
+ public function hasMigrations(string $module): bool
+ {
+ if (! $this->hasPendingMigrations()) {
+ return false;
+ }
+
+ return isset($this->getPendingMigrations()[$module]);
+ }
+
+ /**
+ * Get pending migration matching the given module name
+ *
+ * @param string $module
+ *
+ * @return DbMigrationHook
+ *
+ * @throws NotFoundError When there are no pending migrations matching the given module name
+ */
+ public function getMigration(string $module): DbMigrationHook
+ {
+ if (! $this->hasMigrations($module)) {
+ throw new NotFoundError('There are no pending migrations matching the given name: %s', $module);
+ }
+
+ return $this->getPendingMigrations()[$module];
+ }
+
+ /**
+ * Get the number of all pending migrations
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return count($this->getPendingMigrations());
+ }
+
+ /**
+ * Apply all pending migrations matching the given migration module name
+ *
+ * @param string $module
+ *
+ * @return bool
+ */
+ public function applyByName(string $module): bool
+ {
+ $migration = $this->getMigration($module);
+ if ($migration->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
+ return false;
+ }
+
+ return $this->apply($migration);
+ }
+
+ /**
+ * Apply the given migration hook
+ *
+ * @param DbMigrationHook $hook
+ * @param ?array<string, string> $elevateConfig
+ *
+ * @return bool
+ */
+ public function apply(DbMigrationHook $hook, array $elevateConfig = null): bool
+ {
+ if ($hook->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
+ Logger::error(
+ 'Please apply the Icinga Web pending migration(s) first or apply all the migrations instead'
+ );
+
+ return false;
+ }
+
+ $conn = $hook->getDb();
+ if ($elevateConfig && ! $this->checkRequiredPrivileges($conn)) {
+ $conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
+ }
+
+ if ($hook->run($conn)) {
+ unset($this->pendingMigrations[$hook->getModuleName()]);
+
+ Logger::info('Applied pending %s migrations successfully', $hook->getName());
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Apply all pending modules/framework migrations
+ *
+ * @param ?array<string, string> $elevateConfig
+ *
+ * @return bool
+ */
+ public function applyAll(array $elevateConfig = null): bool
+ {
+ $default = DbMigrationHook::DEFAULT_MODULE;
+ if ($this->hasMigrations($default)) {
+ $migration = $this->getMigration($default);
+ if (! $this->apply($migration, $elevateConfig)) {
+ return false;
+ }
+ }
+
+ $succeeded = true;
+ foreach ($this->getPendingMigrations() as $migration) {
+ if (! $this->apply($migration, $elevateConfig) && $succeeded) {
+ $succeeded = false;
+ }
+ }
+
+ return $succeeded;
+ }
+
+ /**
+ * Yield module and framework pending migrations separately
+ *
+ * @param bool $modules
+ *
+ * @return Generator<DbMigrationHook>
+ */
+ public function yieldMigrations(bool $modules = false): Generator
+ {
+ foreach ($this->getPendingMigrations() as $migration) {
+ if ($modules === $migration->isModule()) {
+ yield $migration;
+ }
+ }
+ }
+
+ /**
+ * Get the required database privileges for database migrations
+ *
+ * @return string[]
+ */
+ public function getRequiredDatabasePrivileges(): array
+ {
+ return ['CREATE','SELECT','INSERT','UPDATE','DELETE','DROP','ALTER','CREATE VIEW','INDEX','EXECUTE','USAGE'];
+ }
+
+ /**
+ * Verify whether all database users of all pending migrations do have the required SQL privileges
+ *
+ * @param ?array<string, string> $elevateConfig
+ * @param bool $canIssueGrant
+ *
+ * @return bool
+ */
+ public function validateDatabasePrivileges(array $elevateConfig = null, bool $canIssueGrant = false): bool
+ {
+ if (! $this->hasPendingMigrations()) {
+ return true;
+ }
+
+ foreach ($this->getPendingMigrations() as $migration) {
+ if (! $this->checkRequiredPrivileges($migration->getDb(), $elevateConfig, $canIssueGrant)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if there are missing grants for the Icinga Web database and fix them
+ *
+ * This fixes the following problems on existing installations:
+ * - Setups made by the wizard have no access to `icingaweb_schema`
+ * - Setups made by the wizard have no DDL grants
+ * - Setups done manually using the advanced documentation chapter have no DDL grants
+ *
+ * @param Sql\Connection $db
+ * @param array<string, string> $elevateConfig
+ */
+ public function fixIcingaWebMysqlGrants(Sql\Connection $db, array $elevateConfig): void
+ {
+ $wizardProperties = (new ReflectionClass(WebWizard::class))
+ ->getDefaultProperties();
+ /** @var array<int, string> $privileges */
+ $privileges = $wizardProperties['databaseUsagePrivileges'];
+ /** @var array<int, string> $tables */
+ $tables = $wizardProperties['databaseTables'];
+
+ $actualUsername = $db->getConfig()->username;
+ $db = $this->elevateDatabaseConnection($db, $elevateConfig);
+ $tool = $this->createDbTool($db);
+ $tool->connectToDb();
+
+ $isPgsql = $db->getAdapter() instanceof Sql\Adapter\Pgsql;
+ // PgSQL doesn't have SELECT privilege on a database level and granting the CREATE,CONNECT, and TEMPORARY
+ // privileges on a database doesn't permit a user to read data from a table. Hence, we have to grant the
+ // required database,schema and table privileges simultaneously.
+ if (! $isPgsql && $tool->checkPrivileges(['SELECT'], [], $actualUsername)) {
+ // Checks only database level grants. If this succeeds, the grants were issued manually.
+ if (! $tool->checkPrivileges($privileges, [], $actualUsername) && $tool->isGrantable($privileges)) {
+ // Any missing grant is now granted on database level as well, not to mix things up
+ $tool->grantPrivileges($privileges, [], $actualUsername);
+ }
+ } elseif (! $tool->checkPrivileges($privileges, $tables, $actualUsername) && $tool->isGrantable($privileges)) {
+ // The above ensures that if this fails, we can safely apply table level grants, as it's
+ // very likely that the existing grants were issued by the setup wizard
+ $tool->grantPrivileges($privileges, $tables, $actualUsername);
+ }
+ }
+
+ /**
+ * Create and return a DbTool instance
+ *
+ * @param Sql\Connection $db
+ *
+ * @return DbTool
+ */
+ private function createDbTool(Sql\Connection $db): DbTool
+ {
+ $config = $db->getConfig();
+
+ return new DbTool(array_merge([
+ 'db' => $config->db,
+ 'host' => $config->host,
+ 'port' => $config->port,
+ 'dbname' => $config->dbname,
+ 'username' => $config->username,
+ 'password' => $config->password,
+ 'charset' => $config->charset
+ ], $db->getAdapter()->getOptions($config)));
+ }
+
+ protected function load(): void
+ {
+ $this->pendingMigrations = [];
+
+ /** @var DbMigrationHook $hook */
+ foreach (Hook::all('DbMigration') as $hook) {
+ if (empty($hook->getMigrations())) {
+ continue;
+ }
+
+ $this->pendingMigrations[$hook->getModuleName()] = $hook;
+ }
+
+ ksort($this->pendingMigrations);
+ }
+
+ /**
+ * Check the required SQL privileges of the given connection
+ *
+ * @param Sql\Connection $conn
+ * @param ?array<string, string> $elevateConfig
+ * @param bool $canIssueGrants
+ *
+ * @return bool
+ */
+ protected function checkRequiredPrivileges(
+ Sql\Connection $conn,
+ array $elevateConfig = null,
+ bool $canIssueGrants = false
+ ): bool {
+ if ($elevateConfig) {
+ $conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
+ }
+
+ $wizardProperties = (new ReflectionClass(WebWizard::class))
+ ->getDefaultProperties();
+ /** @var array<int, string> $tables */
+ $tables = $wizardProperties['databaseTables'];
+
+ $dbTool = $this->createDbTool($conn);
+ $dbTool->connectToDb();
+
+ $isPgsql = $conn->getAdapter() instanceof Sql\Adapter\Pgsql;
+ $privileges = $this->getRequiredDatabasePrivileges();
+ $dbPrivilegesGranted = $dbTool->checkPrivileges($privileges);
+ $tablePrivilegesGranted = $dbTool->checkPrivileges($privileges, $tables);
+ if (! $dbPrivilegesGranted && ($isPgsql || ! $tablePrivilegesGranted)) {
+ return false;
+ }
+
+ if ($isPgsql && ! $tablePrivilegesGranted) {
+ return false;
+ }
+
+ if ($canIssueGrants && ! $dbTool->isGrantable($privileges)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Override the database config of the given connection by the specified new config
+ *
+ * Overrides only the username and password of existing database connection.
+ *
+ * @param Sql\Connection $conn
+ * @param array<string, string> $elevateConfig
+ * @return Sql\Connection
+ */
+ protected function elevateDatabaseConnection(Sql\Connection $conn, array $elevateConfig): Sql\Connection
+ {
+ $config = clone $conn->getConfig();
+ $config->username = $elevateConfig['username'];
+ $config->password = $elevateConfig['password'];
+
+ return new Sql\Connection($config);
+ }
+
+ /**
+ * Get all pending migrations as an array
+ *
+ * @return array<string, mixed>
+ */
+ public function toArray(): array
+ {
+ $framework = [];
+ $serialize = function (DbMigrationHook $hook): array {
+ $serialized = [
+ 'name' => $hook->getName(),
+ 'module' => $hook->getModuleName(),
+ 'isModule' => $hook->isModule(),
+ 'migrated_version' => $hook->getVersion(),
+ 'migrations' => []
+ ];
+
+ foreach ($hook->getMigrations() as $migration) {
+ $serialized['migrations'][$migration->getVersion()] = [
+ 'path' => $migration->getScriptPath(),
+ 'error' => $migration->getLastState()
+ ];
+ }
+
+ return $serialized;
+ };
+
+ foreach ($this->yieldMigrations() as $migration) {
+ $framework[] = $serialize($migration);
+ }
+
+ $modules = [];
+ foreach ($this->yieldMigrations(true) as $migration) {
+ $modules[] = $serialize($migration);
+ }
+
+ return ['System' => $framework, 'Modules' => $modules];
+ }
+}
diff --git a/library/Icinga/Application/Modules/DashboardContainer.php b/library/Icinga/Application/Modules/DashboardContainer.php
new file mode 100644
index 0000000..f3c8bc6
--- /dev/null
+++ b/library/Icinga/Application/Modules/DashboardContainer.php
@@ -0,0 +1,58 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+/**
+ * Container for module dashboards
+ */
+class DashboardContainer extends NavigationItemContainer
+{
+ /**
+ * This dashboard's dashlets
+ *
+ * @var array
+ */
+ protected $dashlets;
+
+ /**
+ * Set this dashboard's dashlets
+ *
+ * @param array $dashlets
+ *
+ * @return $this
+ */
+ public function setDashlets(array $dashlets)
+ {
+ $this->dashlets = $dashlets;
+ return $this;
+ }
+
+ /**
+ * Return this dashboard's dashlets
+ *
+ * @return array
+ */
+ public function getDashlets()
+ {
+ return $this->dashlets ?: array();
+ }
+
+ /**
+ * Add a new dashlet
+ *
+ * @param string $name
+ * @param string $url
+ * @param int $priority
+ *
+ * @return $this
+ */
+ public function add($name, $url, $priority = null)
+ {
+ $this->dashlets[$name] = [
+ 'url' => $url,
+ 'priority' => $priority
+ ];
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Modules/Manager.php b/library/Icinga/Application/Modules/Manager.php
new file mode 100644
index 0000000..55d074d
--- /dev/null
+++ b/library/Icinga/Application/Modules/Manager.php
@@ -0,0 +1,698 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Data\SimpleQuery;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\SystemPermissionException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\NotReadableError;
+
+/**
+ * Module manager that handles detecting, enabling and disabling of modules
+ *
+ * Modules can have 3 states:
+ * * installed, module exists but is disabled
+ * * enabled, module enabled and should be loaded
+ * * loaded, module enabled and loaded via the autoloader
+ *
+ */
+class Manager
+{
+ /**
+ * Namespace for module permissions
+ *
+ * @var string
+ */
+ const MODULE_PERMISSION_NS = 'module/';
+
+ /**
+ * Array of all installed module's base directories
+ *
+ * @var array
+ */
+ private $installedBaseDirs = array();
+
+ /**
+ * Array of all enabled modules base dirs
+ *
+ * @var array
+ */
+ private $enabledDirs = array();
+
+ /**
+ * Array of all module names that have been loaded
+ *
+ * @var array
+ */
+ private $loadedModules = array();
+
+ /**
+ * Reference to Icinga::app
+ *
+ * @var Icinga
+ */
+ private $app;
+
+ /**
+ * The directory that is used to detect enabled modules
+ *
+ * @var string
+ */
+ private $enableDir;
+
+ /**
+ * All paths to look for installed modules that can be enabled
+ *
+ * @var array
+ */
+ private $modulePaths = array();
+
+ /**
+ * Whether we loaded all enabled modules
+ *
+ * @var bool
+ */
+ private $loadedAllEnabledModules = false;
+
+ /**
+ * Create a new instance of the module manager
+ *
+ * @param ApplicationBootstrap $app
+ * @param string $enabledDir Enabled modules location. The application maintains symlinks within
+ * the given path
+ * @param array $availableDirs Installed modules location
+ **/
+ public function __construct($app, $enabledDir, array $availableDirs)
+ {
+ $this->app = $app;
+ $this->modulePaths = $availableDirs;
+ $this->enableDir = $enabledDir;
+ }
+
+ /**
+ * Query interface for the module manager
+ *
+ * @return SimpleQuery
+ */
+ public function select()
+ {
+ $source = new ArrayDatasource($this->getModuleInfo());
+ return $source->select();
+ }
+
+ /**
+ * Check for enabled modules
+ *
+ * Update the internal $enabledDirs property with the enabled modules.
+ *
+ * @throws ConfigurationError If module dir does not exist, is not a directory or not readable
+ */
+ private function detectEnabledModules()
+ {
+ if (! file_exists($parent = dirname($this->enableDir))) {
+ return;
+ }
+ if (! is_readable($parent)) {
+ throw new NotReadableError(
+ 'Cannot read enabled modules. Config directory "%s" is not readable',
+ $parent
+ );
+ }
+
+ if (! file_exists($this->enableDir)) {
+ return;
+ }
+ if (! is_dir($this->enableDir)) {
+ throw new NotReadableError(
+ 'Cannot read enabled modules. Module directory "%s" is not a directory',
+ $this->enableDir
+ );
+ }
+ if (! is_readable($this->enableDir)) {
+ throw new NotReadableError(
+ 'Cannot read enabled modules. Module directory "%s" is not readable',
+ $this->enableDir
+ );
+ }
+ if (($dh = opendir($this->enableDir)) !== false) {
+ $isPhar = substr($this->enableDir, 0, 8) === 'phar:///';
+ $this->enabledDirs = array();
+ while (($file = readdir($dh)) !== false) {
+ if ($file[0] === '.' || $file === 'README') {
+ continue;
+ }
+
+ $link = $this->enableDir . DIRECTORY_SEPARATOR . $file;
+ if (! $isPhar && ! is_link($link)) {
+ Logger::warning(
+ 'Found invalid module in enabledModule directory "%s": "%s" is not a symlink',
+ $this->enableDir,
+ $link
+ );
+ continue;
+ }
+
+ $dir = $isPhar ? $link : realpath($link);
+ if ($dir !== false && is_dir($dir)) {
+ $this->enabledDirs[$file] = $dir;
+ } else {
+ $this->enabledDirs[$file] = null;
+
+ Logger::warning(
+ 'Found invalid module in enabledModule directory "%s": "%s" points to non existing path "%s"',
+ $this->enableDir,
+ $link,
+ $dir
+ );
+ }
+
+ ksort($this->enabledDirs);
+ }
+ closedir($dh);
+ }
+ }
+
+ /**
+ * Try to set all enabled modules in loaded sate
+ *
+ * @return $this
+ * @see Manager::loadModule()
+ */
+ public function loadEnabledModules()
+ {
+ if (! $this->loadedAllEnabledModules) {
+ foreach ($this->listEnabledModules() as $name) {
+ $this->loadModule($name);
+ }
+
+ $this->loadedAllEnabledModules = true;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Whether we loaded all enabled modules
+ *
+ * @return bool
+ */
+ public function loadedAllEnabledModules()
+ {
+ return $this->loadedAllEnabledModules;
+ }
+
+
+ /**
+ * Try to load the module and register it in the application
+ *
+ * @param string $name The name of the module to load
+ * @param mixed $basedir Optional module base directory
+ *
+ * @return $this
+ */
+ public function loadModule($name, $basedir = null)
+ {
+ if ($this->hasLoaded($name)) {
+ return $this;
+ }
+
+ $module = null;
+ if ($basedir === null) {
+ $module = new Module($this->app, $name, $this->getModuleDir($name));
+ } else {
+ $module = new Module($this->app, $name, $basedir);
+ }
+
+ if ($name !== 'ipl' && $name !== 'reactbundle') {
+ $module->register();
+ }
+
+ $this->loadedModules[$name] = $module;
+ return $this;
+ }
+
+ /**
+ * Set the given module to the enabled state
+ *
+ * @param string $name The module to enable
+ * @param bool $force Whether to ignore unmet dependencies
+ *
+ * @return $this
+ * @throws ConfigurationError When trying to enable a module that is not installed
+ * @throws SystemPermissionException When insufficient permissions for the application exist
+ */
+ public function enableModule($name, $force = false)
+ {
+ if (! $this->hasInstalled($name)) {
+ throw new ConfigurationError(
+ 'Cannot enable module "%s". Module is not installed.',
+ $name
+ );
+ }
+
+ if (strtolower(substr($name, 0, 18)) === 'icingaweb2-module-') {
+ throw new ConfigurationError(
+ 'Cannot enable module "%s": Directory name does not match the module\'s name.'
+ . ' Please rename the module to "%s" before enabling.',
+ $name,
+ substr($name, 18)
+ );
+ }
+
+ if ($this->hasUnmetDependencies($name)) {
+ if ($force) {
+ Logger::warning(t('Enabling module "%s" although it has unmet dependencies'), $name);
+ } else {
+ throw new ConfigurationError(
+ t('Module "%s" can\'t be enabled. Module has unmet dependencies'),
+ $name
+ );
+ }
+ }
+
+ clearstatcache(true);
+ $target = $this->installedBaseDirs[$name];
+ $link = $this->enableDir . DIRECTORY_SEPARATOR . $name;
+
+ if (! is_dir($this->enableDir)) {
+ if (!@mkdir($this->enableDir, 0777, true)) {
+ $error = error_get_last();
+ throw new SystemPermissionException(
+ 'Failed to create enabledModules directory "%s" (%s)',
+ $this->enableDir,
+ $error['message']
+ );
+ }
+
+ chmod($this->enableDir, 02770);
+ } elseif (! is_writable($this->enableDir)) {
+ throw new SystemPermissionException(
+ 'Cannot enable module "%s". Check the permissions for the enabledModules directory: %s',
+ $name,
+ $this->enableDir
+ );
+ }
+
+ $this->loadedAllEnabledModules = false;
+
+ if (file_exists($link) && is_link($link)) {
+ return $this;
+ }
+
+ if (! @symlink($target, $link)) {
+ $error = error_get_last();
+ if (strstr($error["message"], "File exists") === false) {
+ throw new SystemPermissionException(
+ 'Cannot enable module "%s" at %s due to file system errors. '
+ . 'Please check path and mounting points because this is not a permission error. '
+ . 'Primary error was: %s',
+ $name,
+ $this->enableDir,
+ $error['message']
+ );
+ }
+ }
+
+ $this->enabledDirs[$name] = $link;
+ $this->loadModule($name);
+ return $this;
+ }
+
+ /**
+ * Disable the given module and remove its enabled state
+ *
+ * @param string $name The name of the module to disable
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError When the module is not installed or it's not a symlink
+ * @throws SystemPermissionException When insufficient permissions for the application exist
+ */
+ public function disableModule($name)
+ {
+ if (! $this->hasEnabled($name)) {
+ throw new ConfigurationError(
+ 'Cannot disable module "%s". Module is not installed.',
+ $name
+ );
+ }
+
+ if (! is_writable($this->enableDir)) {
+ throw new SystemPermissionException(
+ 'Cannot disable module "%s". Check the permissions for the enabledModules directory: %s',
+ $name,
+ $this->enableDir
+ );
+ }
+
+ $link = $this->enableDir . DIRECTORY_SEPARATOR . $name;
+ if (! is_link($link)) {
+ throw new ConfigurationError(
+ 'Cannot disable module %s at %s. '
+ . 'It looks like you have installed this module manually and moved it to your module folder. '
+ . 'In order to dynamically enable and disable modules, you have to create a symlink to '
+ . 'the enabledModules folder.',
+ $name,
+ $this->enableDir
+ );
+ }
+
+ if (is_link($link)) {
+ if (! @unlink($link)) {
+ $error = error_get_last();
+ throw new SystemPermissionException(
+ 'Cannot enable module "%s" at %s due to file system errors. '
+ . 'Please check path and mounting points because this is not a permission error. '
+ . 'Primary error was: %s',
+ $name,
+ $this->enableDir,
+ $error['message']
+ );
+ }
+ }
+
+ unset($this->enabledDirs[$name]);
+ return $this;
+ }
+
+ /**
+ * Return the directory of the given module as a string, optionally with a given sub directoy
+ *
+ * @param string $name The module name to return the module directory of
+ * @param string $subdir The sub directory to append to the path
+ *
+ * @return string
+ *
+ * @throws ProgrammingError When the module is not installed or existing
+ */
+ public function getModuleDir($name, $subdir = '')
+ {
+ if ($this->hasLoaded($name)) {
+ return $this->getModule($name)->getBaseDir() . $subdir;
+ }
+
+ if ($this->hasEnabled($name)) {
+ return $this->enabledDirs[$name]. $subdir;
+ }
+
+ if ($this->hasInstalled($name)) {
+ return $this->installedBaseDirs[$name] . $subdir;
+ }
+
+ throw new ProgrammingError(
+ 'Trying to access uninstalled module dir: %s',
+ $name
+ );
+ }
+
+ /**
+ * Return true when the module with the given name is installed, otherwise false
+ *
+ * @param string $name The module to check for being installed
+ *
+ * @return bool
+ */
+ public function hasInstalled($name)
+ {
+ if (!count($this->installedBaseDirs)) {
+ $this->detectInstalledModules();
+ }
+ return array_key_exists($name, $this->installedBaseDirs);
+ }
+
+ /**
+ * Return true when the given module is in enabled state, otherwise false
+ *
+ * @param string $name The module to check for being enabled
+ *
+ * @return bool
+ */
+ public function hasEnabled($name)
+ {
+ return array_key_exists($name, $this->enabledDirs);
+ }
+
+ /**
+ * Return true when the module is in loaded state, otherwise false
+ *
+ * @param string $name The module to check for being loaded
+ *
+ * @return bool
+ */
+ public function hasLoaded($name)
+ {
+ return array_key_exists($name, $this->loadedModules);
+ }
+
+ /**
+ * Check if a module with the given name is enabled
+ *
+ * Passing a version constraint also verifies that the module's version matches.
+ *
+ * @param string $name
+ * @param string $version
+ *
+ * @return bool
+ */
+ public function has($name, $version = null)
+ {
+ if (! $this->hasEnabled($name)) {
+ return false;
+ } elseif ($version === null || $version === true) {
+ return true;
+ }
+
+ $operator = '=';
+ if (preg_match('/^([<>=]{1,2})\s*v?((?:[\d.]+)(?:.+)?)$/', $version, $match)) {
+ $operator = $match[1];
+ $version = $match[2];
+ }
+
+ $modVersion = ltrim($this->getModule($name)->getVersion(), 'v');
+ return version_compare($modVersion, $version, $operator);
+ }
+
+ /**
+ * Get the currently loaded modules
+ *
+ * @return Module[]
+ */
+ public function getLoadedModules()
+ {
+ return $this->loadedModules;
+ }
+
+ /**
+ * Get a module
+ *
+ * @param string $name Name of the module
+ * @param bool $assertLoaded Whether or not to throw an exception if the module hasn't been loaded
+ *
+ * @return Module
+ * @throws ProgrammingError If the module hasn't been loaded
+ */
+ public function getModule($name, $assertLoaded = true)
+ {
+ if ($this->hasLoaded($name)) {
+ return $this->loadedModules[$name];
+ } elseif (! (bool) $assertLoaded) {
+ return new Module($this->app, $name, $this->getModuleDir($name));
+ }
+ throw new ProgrammingError(
+ 'Can\'t access module %s because it hasn\'t been loaded',
+ $name
+ );
+ }
+
+ /**
+ * Return an array containing information objects for each available module
+ *
+ * Each entry has the following fields
+ * * name, name of the module as a string
+ * * path, path where the module is located as a string
+ * * installed, whether the module is installed or not as a boolean
+ * * enabled, whether the module is enabled or not as a boolean
+ * * loaded, whether the module is loaded or not as a boolean
+ *
+ * @return array
+ */
+ public function getModuleInfo()
+ {
+ $info = array();
+
+ $installed = $this->listInstalledModules();
+ foreach ($installed as $name) {
+ $info[$name] = (object) array(
+ 'name' => $name,
+ 'path' => $this->installedBaseDirs[$name],
+ 'installed' => true,
+ 'enabled' => $this->hasEnabled($name),
+ 'loaded' => $this->hasLoaded($name)
+ );
+ }
+
+ $enabled = $this->listEnabledModules();
+ foreach ($enabled as $name) {
+ $info[$name] = (object) array(
+ 'name' => $name,
+ 'path' => $this->enabledDirs[$name],
+ 'installed' => $this->enabledDirs[$name] !== null,
+ 'enabled' => true,
+ 'loaded' => $this->hasLoaded($name)
+ );
+ }
+
+ return $info;
+ }
+
+ /**
+ * Check if the given module has unmet dependencies
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasUnmetDependencies($name)
+ {
+ $module = $this->getModule($name, false);
+
+ $requiredMods = $module->getRequiredModules();
+
+ if (isset($requiredMods['monitoring'], $requiredMods['icingadb'])) {
+ if (! $this->has('monitoring', $requiredMods['monitoring'])
+ && ! $this->has('icingadb', $requiredMods['icingadb'])
+ ) {
+ return true;
+ }
+
+ unset($requiredMods['monitoring'], $requiredMods['icingadb']);
+ }
+
+ foreach ($requiredMods as $moduleName => $moduleVersion) {
+ if (! $this->has($moduleName, $moduleVersion)) {
+ return true;
+ }
+ }
+
+ $libraries = Icinga::app()->getLibraries();
+
+ $requiredLibs = $module->getRequiredLibraries();
+ foreach ($requiredLibs as $libraryName => $libraryVersion) {
+ if (! $libraries->has($libraryName, $libraryVersion)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return an array containing all enabled module names as strings
+ *
+ * @return array
+ */
+ public function listEnabledModules()
+ {
+ if (count($this->enabledDirs) === 0) {
+ $this->detectEnabledModules();
+ }
+
+ return array_keys($this->enabledDirs);
+ }
+
+ /**
+ * Return an array containing all loaded module names as strings
+ *
+ * @return array
+ */
+ public function listLoadedModules()
+ {
+ return array_keys($this->loadedModules);
+ }
+
+ /**
+ * Return an array of module names from installed modules
+ *
+ * Calls detectInstalledModules() if no module discovery has been performed yet
+ *
+ * @return array
+ *
+ * @see detectInstalledModules()
+ */
+ public function listInstalledModules()
+ {
+ if (!count($this->installedBaseDirs)) {
+ $this->detectInstalledModules();
+ }
+
+ if (count($this->installedBaseDirs)) {
+ return array_keys($this->installedBaseDirs);
+ }
+
+ return array();
+ }
+
+ /**
+ * Detect installed modules from every path provided in modulePaths
+ *
+ * @param array $availableDirs Installed modules location
+ *
+ * @return $this
+ */
+ public function detectInstalledModules(array $availableDirs = null)
+ {
+ $modulePaths = $availableDirs !== null ? $availableDirs : $this->modulePaths;
+ foreach ($modulePaths as $basedir) {
+ $canonical = realpath($basedir);
+ if ($canonical === false) {
+ Logger::warning('Module path "%s" does not exist', $basedir);
+ continue;
+ }
+ if (!is_dir($canonical)) {
+ Logger::error('Module path "%s" is not a directory', $canonical);
+ continue;
+ }
+ if (!is_readable($canonical)) {
+ Logger::error('Module path "%s" is not readable', $canonical);
+ continue;
+ }
+ if (($dh = opendir($canonical)) !== false) {
+ while (($file = readdir($dh)) !== false) {
+ if ($file[0] === '.') {
+ continue;
+ }
+ if (is_dir($canonical . '/' . $file)) {
+ if (! array_key_exists($file, $this->installedBaseDirs)) {
+ $this->installedBaseDirs[$file] = $canonical . '/' . $file;
+ } else {
+ Logger::debug(
+ 'Module "%s" already exists in installation path "%s" and is ignored.',
+ $canonical . '/' . $file,
+ $this->installedBaseDirs[$file]
+ );
+ }
+ }
+ }
+ closedir($dh);
+ }
+ }
+ ksort($this->installedBaseDirs);
+ return $this;
+ }
+
+ /**
+ * Get the directories where to look for installed modules
+ *
+ * @return array
+ */
+ public function getModuleDirs()
+ {
+ return $this->modulePaths;
+ }
+}
diff --git a/library/Icinga/Application/Modules/MenuItemContainer.php b/library/Icinga/Application/Modules/MenuItemContainer.php
new file mode 100644
index 0000000..88599e6
--- /dev/null
+++ b/library/Icinga/Application/Modules/MenuItemContainer.php
@@ -0,0 +1,55 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+/**
+ * Container for module menu items
+ */
+class MenuItemContainer extends NavigationItemContainer
+{
+ /**
+ * This menu item's children
+ *
+ * @var MenuItemContainer[]
+ */
+ protected $children;
+
+ /**
+ * Set this menu item's children
+ *
+ * @param MenuItemContainer[] $children
+ *
+ * @return $this
+ */
+ public function setChildren(array $children)
+ {
+ $this->children = $children;
+ return $this;
+ }
+
+ /**
+ * Return this menu item's children
+ *
+ * @return array
+ */
+ public function getChildren()
+ {
+ return $this->children ?: array();
+ }
+
+ /**
+ * Add a new sub menu
+ *
+ * @param string $name
+ * @param array $properties
+ *
+ * @return MenuItemContainer The newly added sub menu
+ */
+ public function add($name, array $properties = array())
+ {
+ $child = new MenuItemContainer($name, $properties);
+ $this->children[] = $child;
+ return $child;
+ }
+}
diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php
new file mode 100644
index 0000000..6a5afb8
--- /dev/null
+++ b/library/Icinga/Application/Modules/Module.php
@@ -0,0 +1,1451 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+use Exception;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Config;
+use Icinga\Application\Hook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Setup\SetupWizard;
+use Icinga\Util\File;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Widget;
+use ipl\I18n\GettextTranslator;
+use ipl\I18n\StaticTranslator;
+use ipl\I18n\Translation;
+use Zend_Controller_Router_Route;
+use Zend_Controller_Router_Route_Abstract;
+use Zend_Controller_Router_Route_Regex;
+
+/**
+ * Module handling
+ *
+ * Register modules and initialize it
+ */
+class Module
+{
+ use Translation {
+ translate as protected;
+ translatePlural as protected;
+ }
+
+ /**
+ * Module name
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * Base directory of module
+ *
+ * @var string
+ */
+ private $basedir;
+
+ /**
+ * Directory for styles
+ *
+ * @var string
+ */
+ private $cssdir;
+
+ /**
+ * Directory for Javascript
+ *
+ * @var string
+ */
+ private $jsdir;
+
+ /**
+ * Base application directory
+ *
+ * @var string
+ */
+ private $appdir;
+
+ /**
+ * Library directory
+ *
+ * @var string
+ */
+ private $libdir;
+
+ /**
+ * Config directory
+ *
+ * @var string
+ */
+ private $configdir;
+
+ /**
+ * Directory containing translations
+ *
+ * @var string
+ */
+ private $localedir;
+
+ /**
+ * Directory where controllers reside
+ *
+ * @var string
+ */
+ private $controllerdir;
+
+ /**
+ * Directory containing form implementations
+ *
+ * @var string
+ */
+ private $formdir;
+
+ /**
+ * Module bootstrapping script
+ *
+ * @var string
+ */
+ private $runScript;
+
+ /**
+ * Module configuration script
+ *
+ * @var string
+ */
+ private $configScript;
+
+ /**
+ * Module metadata filename
+ *
+ * @var string
+ */
+ private $metadataFile;
+
+ /**
+ * Module metadata (version...)
+ *
+ * @var object
+ */
+ private $metadata;
+
+ /**
+ * Whether we already tried to include the module configuration script
+ *
+ * @var bool
+ */
+ private $triedToLaunchConfigScript = false;
+
+ /**
+ * Whether the module's namespaces have been registered on our autoloader
+ *
+ * @var bool
+ */
+ protected $registeredAutoloader = false;
+
+ /**
+ * Whether this module has been registered
+ *
+ * @var bool
+ */
+ private $registered = false;
+
+ /**
+ * Provided permissions
+ *
+ * @var array
+ */
+ private $permissionList = array();
+
+ /**
+ * Provided restrictions
+ *
+ * @var array
+ */
+ private $restrictionList = array();
+
+ /**
+ * Provided config tabs
+ *
+ * @var array
+ */
+ private $configTabs = array();
+
+ /**
+ * Provided setup wizard
+ *
+ * @var string
+ */
+ private $setupWizard;
+
+ /**
+ * Icinga application
+ *
+ * @var \Icinga\Application\Web
+ */
+ private $app;
+
+ /**
+ * The CSS/LESS files this module provides
+ *
+ * @var array
+ */
+ protected $cssFiles = array();
+
+ /**
+ * The Javascript files this module provides
+ *
+ * @var array
+ */
+ protected $jsFiles = array();
+
+ /**
+ * Routes to add to the route chain
+ *
+ * @var array Array of name-route pairs
+ *
+ * @see addRoute()
+ */
+ protected $routes = array();
+
+ /**
+ * A set of menu elements
+ *
+ * @var MenuItemContainer[]
+ */
+ protected $menuItems = array();
+
+ /**
+ * A set of Pane elements
+ *
+ * @var array
+ */
+ protected $paneItems = array();
+
+ /**
+ * A set of objects representing a searchUrl configuration
+ *
+ * @var array
+ */
+ protected $searchUrls = array();
+
+ /**
+ * This module's user backends providing several authentication mechanisms
+ *
+ * @var array
+ */
+ protected $userBackends = array();
+
+ /**
+ * This module's user group backends
+ *
+ * @var array
+ */
+ protected $userGroupBackends = array();
+
+ /**
+ * This module's configurable navigation items
+ *
+ * @var array
+ */
+ protected $navigationItems = array();
+
+ /**
+ * Create a new module object
+ *
+ * @param ApplicationBootstrap $app
+ * @param string $name
+ * @param string $basedir
+ */
+ public function __construct(ApplicationBootstrap $app, $name, $basedir)
+ {
+ $this->app = $app;
+ $this->name = $name;
+ $this->basedir = $basedir;
+ $this->cssdir = $basedir . '/public/css';
+ $this->jsdir = $basedir . '/public/js';
+ $this->libdir = $basedir . '/library';
+ $this->configdir = $app->getConfigDir('modules/' . $name);
+ $this->appdir = $basedir . '/application';
+ $this->localedir = $basedir . '/application/locale';
+ $this->formdir = $basedir . '/application/forms';
+ $this->controllerdir = $basedir . '/application/controllers';
+ $this->runScript = $basedir . '/run.php';
+ $this->configScript = $basedir . '/configuration.php';
+ $this->metadataFile = $basedir . '/module.info';
+
+ $this->translationDomain = $name;
+ }
+
+ /**
+ * Provide a search URL
+ *
+ * @param string $title
+ * @param string $url
+ * @param int $priority
+ *
+ * @return $this
+ */
+ public function provideSearchUrl($title, $url, $priority = 0)
+ {
+ $this->searchUrls[] = (object) array(
+ 'title' => (string) $title,
+ 'url' => (string) $url,
+ 'priority' => (int) $priority
+ );
+
+ return $this;
+ }
+
+ /**
+ * Get this module's search urls
+ *
+ * @return array
+ */
+ public function getSearchUrls()
+ {
+ $this->launchConfigScript();
+ return $this->searchUrls;
+ }
+
+ /**
+ * Return this module's dashboard
+ *
+ * @return Navigation
+ */
+ public function getDashboard()
+ {
+ $this->launchConfigScript();
+ return $this->createDashboard($this->paneItems);
+ }
+
+ /**
+ * Create and return a new navigation for the given dashboard panes
+ *
+ * @param DashboardContainer[] $panes
+ *
+ * @return Navigation
+ */
+ public function createDashboard(array $panes)
+ {
+ $navigation = new Navigation();
+ foreach ($panes as $pane) {
+ /** @var DashboardContainer $pane */
+ $dashlets = [];
+ foreach ($pane->getDashlets() as $dashletName => $dashletConfig) {
+ $dashlets[$dashletName] = [
+ 'label' => $this->translate($dashletName),
+ 'url' => $dashletConfig['url'],
+ 'priority' => $dashletConfig['priority']
+ ];
+ }
+
+ $navigation->addItem(
+ $pane->getName(),
+ array_merge(
+ $pane->getProperties(),
+ array(
+ 'label' => $this->translate($pane->getName()),
+ 'type' => 'dashboard-pane',
+ 'children' => $dashlets
+ )
+ )
+ );
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Add or get a dashboard pane
+ *
+ * @param string $name
+ * @param array $properties
+ *
+ * @return DashboardContainer
+ */
+ protected function dashboard($name, array $properties = array())
+ {
+ if (array_key_exists($name, $this->paneItems)) {
+ $this->paneItems[$name]->setProperties($properties);
+ } else {
+ $this->paneItems[$name] = new DashboardContainer($name, $properties);
+ }
+
+ return $this->paneItems[$name];
+ }
+
+ /**
+ * Return this module's menu
+ *
+ * @return Navigation
+ */
+ public function getMenu()
+ {
+ $this->launchConfigScript();
+ return Navigation::fromArray($this->createMenu($this->menuItems));
+ }
+
+ /**
+ * Create and return an array structure for the given menu items
+ *
+ * @param MenuItemContainer[] $items
+ *
+ * @return array
+ */
+ private function createMenu(array $items)
+ {
+ $navigation = array();
+ foreach ($items as $item) {
+ /** @var MenuItemContainer $item */
+ $properties = $item->getProperties();
+ $properties['children'] = $this->createMenu($item->getChildren());
+ if (! isset($properties['label'])) {
+ $properties['label'] = $this->translate($item->getName());
+ }
+
+ $navigation[$item->getName()] = $properties;
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Add or get a menu section
+ *
+ * @param string $name
+ * @param array $properties
+ *
+ * @return MenuItemContainer
+ */
+ protected function menuSection($name, array $properties = array())
+ {
+ if (array_key_exists($name, $this->menuItems)) {
+ $this->menuItems[$name]->setProperties($properties);
+ } else {
+ $this->menuItems[$name] = new MenuItemContainer($name, $properties);
+ }
+
+ return $this->menuItems[$name];
+ }
+
+ /**
+ * Register module
+ *
+ * @return bool
+ */
+ public function register()
+ {
+ if ($this->registered) {
+ return true;
+ }
+
+ $this->registerAutoloader();
+ try {
+ $this->launchRunScript();
+ } catch (Exception $e) {
+ Logger::warning(
+ 'Launching the run script %s for module %s failed with the following exception: %s',
+ $this->runScript,
+ $this->name,
+ $e->getMessage()
+ );
+ return false;
+ }
+ $this->registerWebIntegration();
+ $this->registered = true;
+
+ return true;
+ }
+
+ /**
+ * Get whether this module has been registered
+ *
+ * @return bool
+ */
+ public function isRegistered()
+ {
+ return $this->registered;
+ }
+
+ /**
+ * Test for an enabled module by name
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public static function exists($name)
+ {
+ return Icinga::app()->getModuleManager()->hasEnabled($name);
+ }
+
+ /**
+ * Get a module by name
+ *
+ * @param string $name
+ * @param bool $autoload
+ *
+ * @return self
+ *
+ * @throws ProgrammingError When the module is not yet loaded
+ */
+ public static function get($name, $autoload = false)
+ {
+ $manager = Icinga::app()->getModuleManager();
+ if (!$manager->hasLoaded($name)) {
+ if ($autoload === true && $manager->hasEnabled($name)) {
+ $manager->loadModule($name);
+ }
+ }
+ // Throws ProgrammingError when the module is not yet loaded
+ return $manager->getModule($name);
+ }
+
+ /**
+ * Provide an additional CSS/LESS file
+ *
+ * @param string $path The path to the file, relative to self::$cssdir
+ *
+ * @return $this
+ */
+ protected function provideCssFile($path)
+ {
+ $this->cssFiles[] = $this->cssdir . DIRECTORY_SEPARATOR . $path;
+ return $this;
+ }
+
+ /**
+ * Test if module provides css
+ *
+ * @return bool
+ */
+ public function hasCss()
+ {
+ if (file_exists($this->getCssFilename())) {
+ return true;
+ }
+
+ $this->launchConfigScript();
+ return !empty($this->cssFiles);
+ }
+
+ /**
+ * Returns the complete less file name
+ *
+ * @return string
+ */
+ public function getCssFilename()
+ {
+ return $this->cssdir . '/module.less';
+ }
+
+ /**
+ * Return the CSS/LESS files this module provides
+ *
+ * @return array
+ */
+ public function getCssFiles()
+ {
+ $this->launchConfigScript();
+ $files = $this->cssFiles;
+ if (file_exists($this->getCssFilename())) {
+ $files[] = $this->getCssFilename();
+ }
+ return $files;
+ }
+
+ /**
+ * Provide an additional Javascript file
+ *
+ * @param string $path The path to the file, relative to self::$jsdir
+ *
+ * @return $this
+ */
+ protected function provideJsFile($path)
+ {
+ $this->jsFiles[] = $this->jsdir . DIRECTORY_SEPARATOR . $path;
+ return $this;
+ }
+
+ /**
+ * Test if module provides js
+ *
+ * @return bool
+ */
+ public function hasJs()
+ {
+ if (file_exists($this->getJsFilename())) {
+ return true;
+ }
+
+ $this->launchConfigScript();
+ return !empty($this->jsFiles);
+ }
+
+ /**
+ * Returns the complete js file name
+ *
+ * @return string
+ */
+ public function getJsFilename()
+ {
+ return $this->jsdir . '/module.js';
+ }
+
+ /**
+ * Return the Javascript files this module provides
+ *
+ * @return array
+ */
+ public function getJsFiles()
+ {
+ $this->launchConfigScript();
+ $files = $this->jsFiles;
+ $files[] = $this->getJsFilename();
+ return $files;
+ }
+
+ /**
+ * Get the module name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Get the module namespace
+ *
+ * @return string
+ */
+ public function getNamespace()
+ {
+ return 'Icinga\\Module\\' . ucfirst($this->getName());
+ }
+
+ /**
+ * Get the module version
+ *
+ * @return string
+ */
+ public function getVersion()
+ {
+ return $this->metadata()->version;
+ }
+
+ /**
+ * Get the module description
+ *
+ * @return string
+ */
+ public function getDescription()
+ {
+ return $this->metadata()->description;
+ }
+
+ /**
+ * Get the module title (short description)
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->metadata()->title;
+ }
+
+ /**
+ * Get the module dependencies
+ *
+ * @return array
+ * @deprecated Use method getRequiredModules() instead
+ */
+ public function getDependencies()
+ {
+ return $this->metadata()->depends;
+ }
+
+ /**
+ * Get required libraries
+ *
+ * @return array
+ */
+ public function getRequiredLibraries()
+ {
+ $requiredLibraries = $this->metadata()->libraries;
+
+ // Register module requirements for ipl and reactbundle as library requirements
+ $requiredModules = $this->metadata()->modules ?: $this->metadata()->depends;
+ if (isset($requiredModules['ipl']) && ! isset($requiredLibraries['icinga-php-library'])) {
+ $requiredLibraries['icinga-php-library'] = $requiredModules['ipl'];
+ }
+
+ if (isset($requiredModules['reactbundle']) && ! isset($requiredLibraries['icinga-php-thirdparty'])) {
+ $requiredLibraries['icinga-php-thirdparty'] = $requiredModules['reactbundle'];
+ }
+
+ return $requiredLibraries;
+ }
+
+ /**
+ * Get required modules
+ *
+ * @return array
+ */
+ public function getRequiredModules()
+ {
+ $requiredModules = $this->metadata()->modules ?: $this->metadata()->depends;
+
+ $hasIcingadb = isset($requiredModules['icingadb']);
+ if (isset($requiredModules['monitoring']) && ($this->isSupportingIcingadb() || $hasIcingadb)) {
+ $requiredMods = [];
+ $icingadbVersion = true;
+ if ($hasIcingadb) {
+ $icingadbVersion = isset($requiredModules['icingadb']) ? $requiredModules['icingadb'] : true;
+ unset($requiredModules['icingadb']);
+ }
+
+ foreach ($requiredModules as $name => $version) {
+ $requiredMods[$name] = $version;
+ if ($name === 'monitoring') {
+ $requiredMods['icingadb'] = $icingadbVersion;
+ }
+ }
+
+ $requiredModules = $requiredMods;
+ }
+
+ // Both modules are deprecated and their successors are now dependencies of web itself
+ unset($requiredModules['ipl'], $requiredModules['reactbundle']);
+
+ return $requiredModules;
+ }
+
+ /**
+ * Check whether module supports icingadb
+ *
+ * @return bool
+ */
+ protected function isSupportingIcingadb()
+ {
+ $icingadbSupportingModules = [
+ 'cube' => '1.2.0',
+ 'jira' => '1.2.0',
+ 'graphite' => '1.2.0',
+ 'director' => '1.9.0',
+ 'toplevelview' => '0.4.0',
+ 'businessprocess' => '2.4.0'
+ ];
+
+ return array_key_exists($this->getName(), $icingadbSupportingModules)
+ && version_compare($this->getVersion(), $icingadbSupportingModules[$this->getName()], '>=');
+ }
+
+ /**
+ * Fetch module metadata
+ *
+ * @return object
+ */
+ protected function metadata()
+ {
+ if ($this->metadata === null) {
+ $metadata = (object) [
+ 'name' => $this->getName(),
+ 'version' => '0.0.0',
+ 'title' => null,
+ 'description' => '',
+ 'depends' => [],
+ 'libraries' => [],
+ 'modules' => []
+ ];
+
+ if (file_exists($this->metadataFile)) {
+ $key = null;
+ $simpleRequires = false;
+ $file = new File($this->metadataFile, 'r');
+ foreach ($file as $lineno => $line) {
+ $line = rtrim($line);
+
+ if ($key === 'description') {
+ if (empty($line)) {
+ $metadata->description .= "\n";
+ continue;
+ } elseif ($line[0] === ' ') {
+ $metadata->description .= $line;
+ continue;
+ }
+ } elseif (empty($line)) {
+ continue;
+ }
+
+ if (strpos($line, ':') === false) {
+ Logger::debug(
+ "Can't process line %d in %s: Line does not specify a key:value pair"
+ . " nor is it part of the description (indented with a single space)",
+ $lineno,
+ $this->metadataFile
+ );
+
+ break;
+ }
+
+ $parts = preg_split('/:\s+/', $line, 2);
+ if (count($parts) === 1) {
+ $parts[] = '';
+ }
+
+ list($key, $val) = $parts;
+
+ $key = strtolower($key);
+ switch ($key) {
+ case 'requires':
+ if ($val) {
+ $simpleRequires = true;
+ $key = 'libraries';
+ } else {
+ break;
+ }
+
+ // Shares the syntax with `Depends`
+ case ' libraries':
+ case ' modules':
+ if ($simpleRequires && $key[0] === ' ') {
+ Logger::debug(
+ 'Can\'t process line %d in %s: Requirements already registered by a previous line',
+ $lineno,
+ $this->metadataFile
+ );
+ break;
+ }
+
+ $key = ltrim($key);
+ // Shares the syntax with `Depends`
+ case 'depends':
+ if (strpos($val, ' ') === false) {
+ $metadata->{$key}[$val] = true;
+ continue 2;
+ }
+
+ $parts = preg_split('/,\s+/', $val);
+ foreach ($parts as $part) {
+ if (preg_match('/^([\w\-\/]+)\s+\((.+)\)$/', $part, $m)) {
+ $metadata->{$key}[$m[1]] = $m[2];
+ } else {
+ $metadata->{$key}[$part] = true;
+ }
+ }
+
+ break;
+ case 'description':
+ if ($metadata->title === null) {
+ $metadata->title = $val;
+ } else {
+ $metadata->description = $val;
+ }
+ break;
+
+ default:
+ $metadata->{$key} = $val;
+ }
+ }
+ }
+
+ if ($metadata->title === null) {
+ $metadata->title = $this->getName();
+ }
+
+ if ($metadata->description === '') {
+ $metadata->description = t(
+ 'This module has no description'
+ );
+ }
+
+ $this->metadata = $metadata;
+ }
+ return $this->metadata;
+ }
+
+ /**
+ * Get the module's CSS directory
+ *
+ * @return string
+ */
+ public function getCssDir()
+ {
+ return $this->cssdir;
+ }
+
+ /**
+ * Get the module's JS directory
+ *
+ * @return string
+ */
+ public function getJsDir()
+ {
+ return $this->jsdir;
+ }
+
+ /**
+ * Get the module's controller directory
+ *
+ * @return string
+ */
+ public function getControllerDir()
+ {
+ return $this->controllerdir;
+ }
+
+ /**
+ * Get the module's base directory
+ *
+ * @return string
+ */
+ public function getBaseDir()
+ {
+ return $this->basedir;
+ }
+
+ /**
+ * Get the module's application directory
+ *
+ * @return string
+ */
+ public function getApplicationDir()
+ {
+ return $this->appdir;
+ }
+
+ /**
+ * Get the module's library directory
+ *
+ * @return string
+ */
+ public function getLibDir()
+ {
+ return $this->libdir;
+ }
+
+ /**
+ * Get the module's configuration directory
+ *
+ * @return string
+ */
+ public function getConfigDir()
+ {
+ return $this->configdir;
+ }
+
+ /**
+ * Get the module's form directory
+ *
+ * @return string
+ */
+ public function getFormDir()
+ {
+ return $this->formdir;
+ }
+
+ /**
+ * Get the module config
+ *
+ * @param string $file
+ *
+ * @return Config
+ */
+ public function getConfig($file = 'config')
+ {
+ return $this->app->getConfig()->module($this->name, $file);
+ }
+
+ /**
+ * Get provided permissions
+ *
+ * @return array
+ */
+ public function getProvidedPermissions()
+ {
+ $this->launchConfigScript();
+ return $this->permissionList;
+ }
+
+ /**
+ * Get provided restrictions
+ *
+ * @return array
+ */
+ public function getProvidedRestrictions()
+ {
+ $this->launchConfigScript();
+ return $this->restrictionList;
+ }
+
+ /**
+ * Whether the module provides the given restriction
+ *
+ * @param string $name Restriction name
+ *
+ * @return bool
+ */
+ public function providesRestriction($name)
+ {
+ $this->launchConfigScript();
+ return array_key_exists($name, $this->restrictionList);
+ }
+
+ /**
+ * Whether the module provides the given permission
+ *
+ * @param string $name Permission name
+ *
+ * @return bool
+ */
+ public function providesPermission($name)
+ {
+ $this->launchConfigScript();
+ return array_key_exists($name, $this->permissionList);
+ }
+
+ /**
+ * Get the module configuration tabs
+ *
+ * @return \Icinga\Web\Widget\Tabs
+ */
+ public function getConfigTabs()
+ {
+ $this->launchConfigScript();
+ $tabs = Widget::create('tabs');
+ /** @var \Icinga\Web\Widget\Tabs $tabs */
+ $tabs->add('info', array(
+ 'url' => 'config/module',
+ 'urlParams' => array('name' => $this->getName()),
+ 'label' => 'Module: ' . $this->getName()
+ ));
+
+ if ($this->app->getModuleManager()->hasEnabled($this->name)) {
+ foreach ($this->configTabs as $name => $config) {
+ $tabs->add($name, $config);
+ }
+ }
+
+ return $tabs;
+ }
+
+ /**
+ * Whether the module provides a setup wizard
+ *
+ * @return bool
+ */
+ public function providesSetupWizard()
+ {
+ $this->launchConfigScript();
+ if ($this->setupWizard && class_exists($this->setupWizard)) {
+ $wizard = new $this->setupWizard;
+ return $wizard instanceof SetupWizard;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the module's setup wizard
+ *
+ * @return SetupWizard
+ */
+ public function getSetupWizard()
+ {
+ return new $this->setupWizard;
+ }
+
+ /**
+ * Get the module's user backends
+ *
+ * @return array
+ */
+ public function getUserBackends()
+ {
+ $this->launchConfigScript();
+ return $this->userBackends;
+ }
+
+ /**
+ * Get the module's user group backends
+ *
+ * @return array
+ */
+ public function getUserGroupBackends()
+ {
+ $this->launchConfigScript();
+ return $this->userGroupBackends;
+ }
+
+ /**
+ * Return this module's configurable navigation items
+ *
+ * @return array
+ */
+ public function getNavigationItems()
+ {
+ $this->launchConfigScript();
+ return $this->navigationItems;
+ }
+
+ /**
+ * Provide a named permission
+ *
+ * @param string $name Unique permission name
+ * @param string $description Permission description
+ *
+ * @throws IcingaException If the permission is already provided
+ */
+ protected function providePermission($name, $description)
+ {
+ if ($this->providesPermission($name)) {
+ throw new IcingaException(
+ 'Cannot provide permission "%s" twice',
+ $name
+ );
+ }
+ $this->permissionList[$name] = (object) array(
+ 'name' => $name,
+ 'description' => $description
+ );
+ }
+
+ /**
+ * Provide a named restriction
+ *
+ * @param string $name Unique restriction name
+ * @param string $description Restriction description
+ *
+ * @throws IcingaException If the restriction is already provided
+ */
+ protected function provideRestriction($name, $description)
+ {
+ if ($this->providesRestriction($name)) {
+ throw new IcingaException(
+ 'Cannot provide restriction "%s" twice',
+ $name
+ );
+ }
+ $this->restrictionList[$name] = (object) array(
+ 'name' => $name,
+ 'description' => $description
+ );
+ }
+
+ /**
+ * Provide a module config tab
+ *
+ * @param string $name Unique tab name
+ * @param array $config Tab config
+ *
+ * @return $this
+ * @throws ProgrammingError If $config lacks the key 'url'
+ */
+ protected function provideConfigTab($name, $config = array())
+ {
+ if (! array_key_exists('url', $config)) {
+ throw new ProgrammingError('A module config tab MUST provide a "url"');
+ }
+ $config['url'] = $this->getName() . '/' . ltrim($config['url'], '/');
+ $this->configTabs[$name] = $config;
+ return $this;
+ }
+
+ /**
+ * Provide a setup wizard
+ *
+ * @param string $className The name of the class
+ *
+ * @return $this
+ */
+ protected function provideSetupWizard($className)
+ {
+ $this->setupWizard = $className;
+ return $this;
+ }
+
+ /**
+ * Provide a user backend capable of authenticating users
+ *
+ * @param string $identifier The identifier of the new backend type
+ * @param string $className The name of the class
+ *
+ * @return $this
+ */
+ protected function provideUserBackend($identifier, $className)
+ {
+ $this->userBackends[strtolower($identifier)] = $className;
+ return $this;
+ }
+
+ /**
+ * Provide a user group backend
+ *
+ * @param string $identifier The identifier of the new backend type
+ * @param string $className The name of the class
+ *
+ * @return $this
+ */
+ protected function provideUserGroupBackend($identifier, $className)
+ {
+ $this->userGroupBackends[strtolower($identifier)] = $className;
+ return $this;
+ }
+
+ /**
+ * Provide a new type of configurable navigation item with a optional label and config filename
+ *
+ * @param string $type
+ * @param string $label
+ * @param string $config
+ *
+ * @return $this
+ */
+ protected function provideNavigationItem($type, $label = null, $config = null)
+ {
+ $this->navigationItems[$type] = array(
+ 'label' => $label,
+ 'config' => $config
+ );
+
+ return $this;
+ }
+
+ /**
+ * Register module namespaces on our class loader
+ *
+ * @return $this
+ */
+ protected function registerAutoloader()
+ {
+ if ($this->registeredAutoloader) {
+ return $this;
+ }
+
+ $moduleName = ucfirst($this->getName());
+
+ $this->app->getLoader()->registerNamespace(
+ 'Icinga\\Module\\' . $moduleName,
+ $this->getLibDir() . '/'. $moduleName,
+ $this->getApplicationDir()
+ );
+
+ $this->registeredAutoloader = true;
+
+ return $this;
+ }
+
+ /**
+ * Bind text domain for i18n
+ *
+ * @return $this
+ */
+ protected function registerLocales()
+ {
+ if ($this->hasLocales() && StaticTranslator::$instance instanceof GettextTranslator) {
+ StaticTranslator::$instance->addTranslationDirectory($this->localedir, $this->name);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get whether the module has translations
+ */
+ public function hasLocales()
+ {
+ return file_exists($this->localedir) && is_dir($this->localedir);
+ }
+
+ /**
+ * List all available locales
+ *
+ * @return array Locale list
+ */
+ public function listLocales()
+ {
+ $locales = array();
+ if (! $this->hasLocales()) {
+ return $locales;
+ }
+
+ $dh = opendir($this->localedir);
+ while (false !== ($file = readdir($dh))) {
+ $filename = $this->localedir . DIRECTORY_SEPARATOR . $file;
+ if (preg_match('/^[a-z]{2}_[A-Z]{2}$/', $file) && is_dir($filename)) {
+ $locales[] = $file;
+ }
+ }
+ closedir($dh);
+ sort($locales);
+ return $locales;
+ }
+
+ /**
+ * Register web integration
+ *
+ * Add controller directory to mvc
+ *
+ * @return $this
+ */
+ protected function registerWebIntegration()
+ {
+ if (! $this->app->isWeb()) {
+ return $this;
+ }
+
+ return $this
+ ->registerLocales()
+ ->registerRoutes();
+ }
+
+ /**
+ * Add routes for static content and any route added via {@link addRoute()} to the route chain
+ *
+ * @return $this
+ */
+ protected function registerRoutes()
+ {
+ $router = $this->app->getFrontController()->getRouter();
+
+ // TODO: We should not be required to do this. Please check dispatch()
+ $this->app->getFrontController()->addControllerDirectory(
+ $this->getControllerDir(),
+ $this->getName()
+ );
+
+ /** @var \Zend_Controller_Router_Rewrite $router */
+ foreach ($this->routes as $name => $route) {
+ $router->addRoute($name, $route);
+ }
+ $router->addRoute(
+ $this->name . '_jsprovider',
+ new Zend_Controller_Router_Route(
+ 'js/' . $this->name . '/:file',
+ array(
+ 'action' => 'javascript',
+ 'controller' => 'static',
+ 'module' => 'default',
+ 'module_name' => $this->name
+ )
+ )
+ );
+ $router->addRoute(
+ $this->name . '_img',
+ new Zend_Controller_Router_Route_Regex(
+ 'img/' . $this->name . '/(.+)',
+ array(
+ 'action' => 'img',
+ 'controller' => 'static',
+ 'module' => 'default',
+ 'module_name' => $this->name
+ ),
+ array(
+ 1 => 'file'
+ )
+ )
+ );
+ return $this;
+ }
+
+ /**
+ * Run module bootstrap script
+ *
+ * @return $this
+ */
+ protected function launchRunScript()
+ {
+ return $this->includeScript($this->runScript);
+ }
+
+ /**
+ * Include a php script if it is readable
+ *
+ * @param string $file File to include
+ *
+ * @return $this
+ */
+ protected function includeScript($file)
+ {
+ if (file_exists($file) && is_readable($file)) {
+ include $file;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Run module config script
+ *
+ * @return $this
+ */
+ protected function launchConfigScript()
+ {
+ if ($this->triedToLaunchConfigScript) {
+ return $this;
+ }
+ $this->triedToLaunchConfigScript = true;
+ $this->registerAutoloader();
+ return $this->includeScript($this->configScript);
+ }
+
+ protected function slashesToNamespace($class)
+ {
+ $list = explode('/', $class);
+ foreach ($list as &$part) {
+ $part = ucfirst($part);
+ }
+
+ return implode('\\', $list);
+ }
+
+ /**
+ * Provide a hook implementation
+ *
+ * @param string $name Name of the hook for which to provide an implementation
+ * @param string $implementation Fully qualified name of the class providing the hook implementation.
+ * Defaults to the module's ProvidedHook namespace plus the hook's name for the
+ * class name
+ * @param bool $alwaysRun To run the hook always (e.g. without permission check)
+ *
+ * @return $this
+ */
+ protected function provideHook($name, $implementation = null, $alwaysRun = false)
+ {
+ if ($implementation === null) {
+ $implementation = $name;
+ }
+
+ if (strpos($implementation, '\\') === false) {
+ $class = $this->getNamespace()
+ . '\\ProvidedHook\\'
+ . $this->slashesToNamespace($implementation);
+ } else {
+ $class = $implementation;
+ }
+
+ Hook::register($name, $class, $class, $alwaysRun);
+ return $this;
+ }
+
+ /**
+ * Add a route which will be added to the route chain
+ *
+ * @param string $name Name of the route
+ * @param Zend_Controller_Router_Route_Abstract $route Instance of the route
+ *
+ * @return $this
+ * @see registerRoutes()
+ */
+ protected function addRoute($name, Zend_Controller_Router_Route_Abstract $route)
+ {
+ $this->routes[$name] = $route;
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Modules/NavigationItemContainer.php b/library/Icinga/Application/Modules/NavigationItemContainer.php
new file mode 100644
index 0000000..c906ccb
--- /dev/null
+++ b/library/Icinga/Application/Modules/NavigationItemContainer.php
@@ -0,0 +1,117 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Container for module navigation items
+ */
+abstract class NavigationItemContainer
+{
+ /**
+ * This navigation item's name
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * This navigation item's properties
+ *
+ * @var array
+ */
+ protected $properties;
+
+ /**
+ * Create a new NavigationItemContainer
+ *
+ * @param string $name
+ * @param array $properties
+ */
+ public function __construct($name, array $properties = array())
+ {
+ $this->name = $name;
+ $this->properties = $properties;
+ }
+
+ /**
+ * Set this menu item's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Return this menu item's name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set this menu item's properties
+ *
+ * @param array $properties
+ *
+ * @return $this
+ */
+ public function setProperties(array $properties)
+ {
+ $this->properties = $properties;
+ return $this;
+ }
+
+ /**
+ * Return this menu item's properties
+ *
+ * @return array
+ */
+ public function getProperties()
+ {
+ return $this->properties ?: array();
+ }
+
+ /**
+ * Allow dynamic setters and getters for properties
+ *
+ * @param string $name
+ * @param array $arguments
+ *
+ * @return mixed
+ *
+ * @throws ProgrammingError In case the called method is not supported
+ */
+ public function __call($name, $arguments)
+ {
+ if (method_exists($this, $name)) {
+ return call_user_func(array($this, $name), $this, $arguments);
+ }
+
+ $type = substr($name, 0, 3);
+ if ($type !== 'set' && $type !== 'get') {
+ throw new ProgrammingError(
+ 'Dynamic method %s is not supported. Only getters (get*) and setters (set*) are.',
+ $name
+ );
+ }
+
+ $propertyName = strtolower(join('_', preg_split('~(?=[A-Z])~', lcfirst(substr($name, 3)))));
+ if ($type === 'set') {
+ $this->properties[$propertyName] = $arguments[0];
+ return $this;
+ } else { // $type === 'get'
+ return array_key_exists($propertyName, $this->properties) ? $this->properties[$propertyName] : null;
+ }
+ }
+}
diff --git a/library/Icinga/Application/Platform.php b/library/Icinga/Application/Platform.php
new file mode 100644
index 0000000..185a69e
--- /dev/null
+++ b/library/Icinga/Application/Platform.php
@@ -0,0 +1,435 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+/**
+ * Platform tests for icingaweb
+ */
+class Platform
+{
+ /**
+ * Domain name
+ *
+ * @var string
+ */
+ protected static $domain;
+
+ /**
+ * Host name
+ *
+ * @var string
+ */
+ protected static $hostname;
+
+ /**
+ * Fully qualified domain name
+ *
+ * @var string
+ */
+ protected static $fqdn;
+
+ /**
+ * Return the operating system's name
+ *
+ * @return string
+ */
+ public static function getOperatingSystemName()
+ {
+ return php_uname('s');
+ }
+
+ /**
+ * Test of windows
+ *
+ * @return bool
+ */
+ public static function isWindows()
+ {
+ return strtoupper(substr(self::getOperatingSystemName(), 0, 3)) === 'WIN';
+ }
+
+ /**
+ * Test of linux
+ *
+ * @return bool
+ */
+ public static function isLinux()
+ {
+ return strtoupper(substr(self::getOperatingSystemName(), 0, 5)) === 'LINUX';
+ }
+
+ /**
+ * Return the Linux distribution's name
+ * or 'linux' if the name could not be found out
+ * or false if the OS isn't Linux or an error occurred
+ *
+ * @param int $reliable
+ * 3: Only parse /etc/os-release (or /usr/lib/os-release).
+ * For the paranoid ones.
+ * 2: If that (3) doesn't help, check /etc/*-release, too.
+ * If something is unclear, return 'linux'.
+ * 1: Almost equal to mode 2. The possible return values also include:
+ * 'redhat' -- unclear whether RHEL/Fedora/...
+ * 'suse' -- unclear whether SLES/openSUSE/...
+ * 0: If even that (1) doesn't help, check /proc/version, too.
+ * This may not work (as expected) on LXC containers!
+ * (No reliability at all!)
+ *
+ * @return string|bool
+ */
+ public static function getLinuxDistro($reliable = 2)
+ {
+ if (! self::isLinux()) {
+ return false;
+ }
+
+ foreach (array('/etc/os-release', '/usr/lib/os-release') as $osReleaseFile) {
+ if (false === ($osRelease = @file(
+ $osReleaseFile,
+ FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES
+ ))) {
+ continue;
+ }
+
+ foreach ($osRelease as $osInfo) {
+ if (false === ($res = @preg_match('/(?<!.)[ \t]*#/ms', $osInfo))) {
+ return false;
+ }
+ if ($res === 1) {
+ continue;
+ }
+
+ $matches = array();
+ if (false === ($res = @preg_match(
+ '/(?<!.)[ \t]*ID[ \t]*=[ \t]*(\'|"|)(.*?)(?:\1)[ \t]*(?!.)/msi',
+ $osInfo,
+ $matches
+ ))) {
+ return false;
+ }
+ if (! ($res === 0 || $matches[2] === '' || $matches[2] === 'linux')) {
+ return $matches[2];
+ }
+ }
+ }
+
+ if ($reliable > 2) {
+ return 'linux';
+ }
+
+ foreach (array(
+ 'fedora' => '/etc/fedora-release',
+ 'centos' => '/etc/centos-release'
+ ) as $distro => $releaseFile) {
+ if (! (false === (
+ $release = @file_get_contents($releaseFile)
+ ) || false === strpos(strtolower($release), $distro))) {
+ return $distro;
+ }
+ }
+
+ if (false !== ($release = @file_get_contents('/etc/redhat-release'))) {
+ $release = strtolower($release);
+ if (false !== strpos($release, 'red hat enterprise linux')) {
+ return 'rhel';
+ }
+ foreach (array('fedora', 'centos') as $distro) {
+ if (false !== strpos($release, $distro)) {
+ return $distro;
+ }
+ }
+ return $reliable < 2 ? 'redhat' : 'linux';
+ }
+
+ if (false !== ($release = @file_get_contents('/etc/SuSE-release'))) {
+ $release = strtolower($release);
+ foreach (array(
+ 'opensuse' => 'opensuse',
+ 'sles' => 'suse linux enterprise server',
+ 'sled' => 'suse linux enterprise desktop'
+ ) as $distro => $name) {
+ if (false !== strpos($release, $name)) {
+ return $distro;
+ }
+ }
+ return $reliable < 2 ? 'suse' : 'linux';
+ }
+
+ if ($reliable < 1) {
+ if (false === ($procVersion = @file_get_contents('/proc/version'))) {
+ return false;
+ }
+ $procVersion = strtolower($procVersion);
+ foreach (array(
+ 'redhat' => 'red hat',
+ 'suse' => 'suse linux',
+ 'ubuntu' => 'ubuntu',
+ 'debian' => 'debian'
+ ) as $distro => $name) {
+ if (false !== strpos($procVersion, $name)) {
+ return $distro;
+ }
+ }
+ }
+
+ return 'linux';
+ }
+
+ /**
+ * Test of CLI environment
+ *
+ * @return bool
+ */
+ public static function isCli()
+ {
+ if (PHP_SAPI == 'cli') {
+ return true;
+ } elseif ((PHP_SAPI == 'cgi' || PHP_SAPI == 'cgi-fcgi')
+ && empty($_SERVER['SERVER_NAME'])) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Get the hostname
+ *
+ * @return string
+ */
+ public static function getHostname()
+ {
+ if (self::$hostname === null) {
+ self::discoverHostname();
+ }
+ return self::$hostname;
+ }
+
+ /**
+ * Get the domain name
+ *
+ * @return string
+ */
+ public static function getDomain()
+ {
+ if (self::$domain === null) {
+ self::discoverHostname();
+ }
+ return self::$domain;
+ }
+
+ /**
+ * Get the fully qualified domain name
+ *
+ * @return string
+ */
+ public static function getFqdn()
+ {
+ if (self::$fqdn === null) {
+ self::discoverHostname();
+ }
+ return self::$fqdn;
+ }
+
+ /**
+ * Initialize domain and host strings
+ */
+ protected static function discoverHostname()
+ {
+ self::$hostname = gethostname();
+ self::$fqdn = gethostbyaddr(gethostbyname(self::$hostname));
+
+ if (substr(self::$fqdn, 0, strlen(self::$hostname)) === self::$hostname) {
+ self::$domain = substr(self::$fqdn, strlen(self::$hostname) + 1);
+ } else {
+ $parts = preg_split('~\.~', self::$hostname, 2);
+ self::$domain = array_shift($parts);
+ }
+ }
+
+ /**
+ * Return the version of PHP
+ *
+ * @return string
+ */
+ public static function getPhpVersion()
+ {
+ return phpversion();
+ }
+
+ /**
+ * Return the username PHP is running as
+ *
+ * @return ?string
+ */
+ public static function getPhpUser()
+ {
+ if (static::isWindows()) {
+ return get_current_user(); // http://php.net/manual/en/function.get-current-user.php#75059
+ }
+
+ if (function_exists('posix_geteuid')) {
+ $userInfo = posix_getpwuid(posix_geteuid());
+ return $userInfo['name'];
+ }
+ }
+
+ /**
+ * Test for php extension
+ *
+ * @param string $extensionName E.g. mysql, ldap
+ *
+ * @return bool
+ */
+ public static function extensionLoaded($extensionName)
+ {
+ return extension_loaded($extensionName);
+ }
+
+ /**
+ * Return the value for the given PHP configuration option
+ *
+ * @param string $option The option name for which to return the value
+ *
+ * @return string|false
+ */
+ public static function getPhpConfig($option)
+ {
+ return ini_get($option);
+ }
+
+ /**
+ * Return whether the given class exists
+ *
+ * @param string $name The name of the class to check
+ *
+ * @return bool
+ */
+ public static function classExists($name)
+ {
+ if (@class_exists($name)) {
+ return true;
+ }
+
+ if (strpos($name, '_') !== false) {
+ // Assume it's a Zend-Framework class
+ return (@include str_replace('_', '/', $name) . '.php') !== false;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return whether it's possible to connect to a LDAP server
+ *
+ * Checks whether the ldap extension is loaded
+ *
+ * @return bool
+ */
+ public static function hasLdapSupport()
+ {
+ return static::extensionLoaded('ldap');
+ }
+
+ /**
+ * Return whether it's possible to connect to any of the supported database servers
+ *
+ * @return bool
+ */
+ public static function hasDatabaseSupport()
+ {
+ return static::hasMssqlSupport() || static::hasMysqlSupport() || static::hasOciSupport()
+ || static::hasOracleSupport() || static::hasPostgresqlSupport();
+ }
+
+ /**
+ * Return whether it's possible to connect to a MSSQL database
+ *
+ * Checks whether the mssql/dblib pdo or sqlsrv extension has
+ * been loaded and Zend framework adapter for MSSQL is available
+ *
+ * @return bool
+ */
+ public static function hasMssqlSupport()
+ {
+ if ((static::extensionLoaded('mssql') || static::extensionLoaded('pdo_dblib'))
+ && static::classExists('Zend_Db_Adapter_Pdo_Mssql')
+ ) {
+ return true;
+ }
+
+ return static::extensionLoaded('sqlsrv') && static::classExists('Zend_Db_Adapter_Sqlsrv');
+ }
+
+ /**
+ * Return whether it's possible to connect to a MySQL database
+ *
+ * Checks whether the mysql pdo extension has been loaded and the Zend framework adapter for MySQL is available
+ *
+ * @return bool
+ */
+ public static function hasMysqlSupport()
+ {
+ return static::extensionLoaded('pdo_mysql') && static::classExists('Zend_Db_Adapter_Pdo_Mysql');
+ }
+
+ /**
+ * Return whether it's possible to connect to a IBM DB2 database
+ *
+ * Checks whether the ibm pdo extension has been loaded and the Zend framework adapter for IBM is available
+ *
+ * @return bool
+ */
+ public static function hasIbmSupport()
+ {
+ return static::extensionLoaded('pdo_ibm') && static::classExists('Zend_Db_Adapter_Pdo_Ibm');
+ }
+
+ /**
+ * Return whether it's possible to connect to a Oracle database using OCI8
+ *
+ * Checks whether the OCI8 extension has been loaded and the Zend framework adapter for Oracle is available
+ *
+ * @return bool
+ */
+ public static function hasOciSupport()
+ {
+ return static::extensionLoaded('oci8') && static::classExists('Zend_Db_Adapter_Oracle');
+ }
+
+ /**
+ * Return whether it's possible to connect to a Oracle database using PDO_OCI
+ *
+ * Checks whether the OCI PDO extension has been loaded and the Zend framework adapter for Oci is available
+ *
+ * @return bool
+ */
+ public static function hasOracleSupport()
+ {
+ return static::extensionLoaded('pdo_oci') && static::classExists('Zend_Db_Adapter_Pdo_Oci');
+ }
+
+ /**
+ * Return whether it's possible to connect to a PostgreSQL database
+ *
+ * Checks whether the pgsql pdo extension has been loaded and the Zend framework adapter for PostgreSQL is available
+ *
+ * @return bool
+ */
+ public static function hasPostgresqlSupport()
+ {
+ return static::extensionLoaded('pdo_pgsql') && static::classExists('Zend_Db_Adapter_Pdo_Pgsql');
+ }
+
+ /**
+ * Return whether it's possible to connect to a SQLite database
+ *
+ * Checks whether the sqlite pdo extension has been loaded and the Zend framework adapter for SQLite is available
+ *
+ * @return bool
+ */
+ public static function hasSqliteSupport()
+ {
+ return static::extensionLoaded('pdo_sqlite') && static::classExists('Zend_Db_Adapter_Pdo_Sqlite');
+ }
+}
diff --git a/library/Icinga/Application/ProvidedHook/DbMigration.php b/library/Icinga/Application/ProvidedHook/DbMigration.php
new file mode 100644
index 0000000..899dbf6
--- /dev/null
+++ b/library/Icinga/Application/ProvidedHook/DbMigration.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\ProvidedHook;
+
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Common\Database;
+use Icinga\Model\Schema;
+use ipl\Orm\Query;
+use ipl\Sql\Connection;
+
+class DbMigration extends DbMigrationHook
+{
+ use Database {
+ getDb as private getWebDb;
+ }
+
+ public function getDb(): Connection
+ {
+ return $this->getWebDb();
+ }
+
+ public function getName(): string
+ {
+ return $this->translate('Icinga Web');
+ }
+
+ public function providedDescriptions(): array
+ {
+ return [];
+ }
+
+ public function getVersion(): string
+ {
+ if ($this->version === null) {
+ $conn = $this->getDb();
+ $schemaQuery = $this->getSchemaQuery()
+ ->orderBy('id', SORT_DESC)
+ ->limit(2);
+
+ if (static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
+ /** @var Schema $schema */
+ foreach ($schemaQuery as $schema) {
+ if ($schema->success) {
+ $this->version = $schema->version;
+
+ break;
+ }
+ }
+
+ if (! $this->version) {
+ $this->version = '2.12.0';
+ }
+ } elseif (static::tableExists($conn, $schemaQuery->getModel()->getTableName())
+ || static::getColumnCollation($conn, 'icingaweb_user_preference', 'username') === 'utf8mb4_unicode_ci'
+ ) {
+ $this->version = '2.11.0';
+ } elseif (static::tableExists($conn, 'icingaweb_rememberme')) {
+ $randomIvType = static::getColumnType($conn, 'icingaweb_rememberme', 'random_iv');
+ if ($randomIvType === 'varchar(32)') {
+ $this->version = '2.9.1';
+ } else {
+ $this->version = '2.9.0';
+ }
+ } else {
+ $usernameType = static::getColumnType($conn, 'icingaweb_group_membership', 'username');
+ if ($usernameType === 'varchar(254)') {
+ $this->version = '2.5.0';
+ } else {
+ $this->version = '2.0.0';
+ }
+ }
+ }
+
+ return $this->version;
+ }
+
+ protected function getSchemaQuery(): Query
+ {
+ return Schema::on($this->getDb());
+ }
+}
diff --git a/library/Icinga/Application/StaticWeb.php b/library/Icinga/Application/StaticWeb.php
new file mode 100644
index 0000000..5c64dcb
--- /dev/null
+++ b/library/Icinga/Application/StaticWeb.php
@@ -0,0 +1,21 @@
+<?php
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application;
+
+require_once dirname(__FILE__) . '/EmbeddedWeb.php';
+
+class StaticWeb extends EmbeddedWeb
+{
+ protected function bootstrap()
+ {
+ return $this
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->loadConfig()
+ ->setupLogging()
+ ->setupLogger()
+ ->setupRequest()
+ ->setupResponse();
+ }
+}
diff --git a/library/Icinga/Application/Test.php b/library/Icinga/Application/Test.php
new file mode 100644
index 0000000..74321ea
--- /dev/null
+++ b/library/Icinga/Application/Test.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Icinga\Application;
+
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+
+require_once __DIR__ . '/Cli.php';
+
+class Test extends Cli
+{
+ protected $isCli = false;
+
+ /** @var Request */
+ private $request;
+
+ /** @var Response */
+ private $response;
+
+ public function setRequest(Request $request): void
+ {
+ $this->request = $request;
+ }
+
+ public function getRequest(): Request
+ {
+ assert(isset($this->request), 'BaseTestCase should have set the request');
+
+ return $this->request;
+ }
+
+ public function setResponse(Response $response): void
+ {
+ $this->response = $response;
+ }
+
+ public function getResponse(): Response
+ {
+ assert(isset($this->request), 'BaseTestCase should have set the response');
+
+ return $this->response;
+ }
+
+ public function getFrontController()
+ {
+ return $this; // Callers are expected to only call getRequest or getResponse, hence the app should suffice
+ }
+
+ protected function bootstrap()
+ {
+ $this->assertRunningOnCli();
+ $this->setupLogging()
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->setupComposerAutoload()
+ ->loadConfig()
+ ->setupModuleAutoloaders()
+ ->setupTimezone()
+ ->prepareInternationalization()
+ ->setupInternationalization()
+ ->parseBasicParams()
+ ->setupLogger()
+ ->setupModuleManager()
+ ->setupUserBackendFactory()
+ ->setupFakeAuthentication();
+ }
+
+ public function setupAutoloader()
+ {
+ parent::setupAutoloader();
+
+ if (($icingaLibDir = getenv('ICINGAWEB_ICINGA_LIB')) !== false) {
+ $this->getLoader()->registerNamespace('Icinga', $icingaLibDir);
+ }
+
+ // Conflicts with `Tests\Icinga\Module\...\Lib`. But it seems it's not needed anyway...
+ //$this->getLoader()->registerNamespace('Tests', $this->getBaseDir('test/php/library'));
+ $this->getLoader()->registerNamespace('Tests\\Icinga\\Lib', $this->getBaseDir('test/php/Lib'));
+
+ return $this;
+ }
+
+ protected function detectTimezone()
+ {
+ return 'UTC';
+ }
+
+ private function setupModuleAutoloaders(): self
+ {
+ $modulePaths = getenv('ICINGAWEB_MODULE_DIRS');
+
+ if ($modulePaths) {
+ $modulePaths = preg_split('/:/', $modulePaths, -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ if (! $modulePaths) {
+ $modulePaths = [];
+ foreach ($this->getAvailableModulePaths() as $path) {
+ $candidates = array_flip(scandir($path));
+ unset($candidates['.'], $candidates['..']);
+ foreach ($candidates as $candidate => $_) {
+ $modulePaths[] = "$path/$candidate";
+ }
+ }
+ }
+
+ foreach ($modulePaths as $path) {
+ $module = basename($path);
+
+ $moduleNamespace = 'Icinga\\Module\\' . ucfirst($module);
+ $moduleLibraryPath = "$path/library/" . ucfirst($module);
+
+ if (is_dir($moduleLibraryPath)) {
+ $this->getLoader()->registerNamespace($moduleNamespace, $moduleLibraryPath, "$path/application");
+ }
+
+ $moduleTestPath = "$path/test/php/Lib";
+ if (is_dir($moduleTestPath)) {
+ $this->getLoader()->registerNamespace('Tests\\' . $moduleNamespace . '\\Lib', $moduleTestPath);
+ }
+
+ $composerAutoloader = "$path/vendor/autoload.php";
+ if (file_exists($composerAutoloader)) {
+ require_once $composerAutoloader;
+ }
+ }
+
+ return $this;
+ }
+
+ private function setupComposerAutoload(): self
+ {
+ $vendorAutoload = $this->getBaseDir('/vendor/autoload.php');
+ if (file_exists($vendorAutoload)) {
+ require_once $vendorAutoload;
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Version.php b/library/Icinga/Application/Version.php
new file mode 100644
index 0000000..be804f1
--- /dev/null
+++ b/library/Icinga/Application/Version.php
@@ -0,0 +1,65 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+/**
+ * Retrieve the version of Icinga Web 2
+ */
+class Version
+{
+ const VERSION = '2.12.1';
+
+ /**
+ * Get the version of this instance of Icinga Web 2
+ *
+ * @return array
+ */
+ public static function get()
+ {
+ $version = array('appVersion' => self::VERSION);
+ preg_match('/2.(\d+)\./', self::VERSION, $matches);
+ $version['docVersion'] = isset($matches[1]) ? '2.' . $matches[1] : null;
+
+ if (false !== ($appVersion = @file_get_contents(Icinga::app()->getApplicationDir('VERSION')))) {
+ $matches = array();
+ if (@preg_match('/^(?P<gitCommitID>\w+) (?P<gitCommitDate>\S+)/', $appVersion, $matches)) {
+ return array_merge($version, $matches);
+ }
+ }
+
+ $gitCommitId = static::getGitHead(Icinga::app()->getBaseDir());
+ if ($gitCommitId !== false) {
+ $version['gitCommitID'] = $gitCommitId;
+ }
+
+ return $version;
+ }
+
+ /**
+ * Get the current commit of the Git repository in the given path
+ *
+ * @param string $repo Path to the Git repository
+ * @param bool $bare Whether the Git repository is bare
+ *
+ * @return string|bool False if not available
+ */
+ public static function getGitHead($repo, $bare = false)
+ {
+ if (! $bare) {
+ $repo .= '/.git';
+ }
+
+ $head = @file_get_contents($repo . '/HEAD');
+
+ if ($head !== false) {
+ if (preg_match('/^ref: (.+)/', $head, $matches)) {
+ return @file_get_contents($repo . '/' . $matches[1]);
+ }
+
+ return $head;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php
new file mode 100644
index 0000000..934af07
--- /dev/null
+++ b/library/Icinga/Application/Web.php
@@ -0,0 +1,509 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+require_once __DIR__ . '/EmbeddedWeb.php';
+
+use ErrorException;
+use ipl\I18n\GettextTranslator;
+use ipl\I18n\Locale;
+use ipl\I18n\StaticTranslator;
+use Zend_Controller_Action_HelperBroker;
+use Zend_Controller_Front;
+use Zend_Controller_Router_Route;
+use Zend_Layout;
+use Zend_Paginator;
+use Zend_View_Helper_PaginationControl;
+use Icinga\Authentication\Auth;
+use Icinga\User;
+use Icinga\Util\DirectoryIterator;
+use Icinga\Util\TimezoneDetect;
+use Icinga\Web\Controller\Dispatcher;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Notification;
+use Icinga\Web\Session;
+use Icinga\Web\Session\Session as BaseSession;
+use Icinga\Web\StyleSheet;
+use Icinga\Web\View;
+
+/**
+ * Use this if you want to make use of Icinga functionality in other web projects
+ *
+ * Usage example:
+ * <code>
+ * use Icinga\Application\Web;
+ * Web::start();
+ * </code>
+ */
+class Web extends EmbeddedWeb
+{
+ /**
+ * View object
+ *
+ * @var View
+ */
+ private $viewRenderer;
+
+ /**
+ * Zend front controller instance
+ *
+ * @var Zend_Controller_Front
+ */
+ private $frontController;
+
+ /**
+ * Session object
+ *
+ * @var BaseSession
+ */
+ private $session;
+
+ /**
+ * User object
+ *
+ * @var User
+ */
+ private $user;
+
+ /** @var array */
+ protected $accessibleMenuItems;
+
+ /**
+ * Identify web bootstrap
+ *
+ * @var bool
+ */
+ protected $isWeb = true;
+
+ /**
+ * Initialize all together
+ *
+ * @return $this
+ */
+ protected function bootstrap()
+ {
+ return $this
+ ->setupLogging()
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->loadConfig()
+ ->setupLogger()
+ ->setupRequest()
+ ->setupSession()
+ ->setupNotifications()
+ ->setupResponse()
+ ->setupZendMvc()
+ ->prepareInternationalization()
+ ->setupModuleManager()
+ ->loadSetupModuleIfNecessary()
+ ->loadEnabledModules()
+ ->setupRoute()
+ ->setupPagination()
+ ->setupUserBackendFactory()
+ ->setupUser()
+ ->setupTimezone()
+ ->setupInternationalization()
+ ->setupFatalErrorHandling()
+ ->registerApplicationHooks();
+ }
+
+ /**
+ * Get themes provided by Web 2 and all enabled modules
+ *
+ * @return string[] Array of theme names as keys and values
+ */
+ public function getThemes()
+ {
+ $themes = array(StyleSheet::DEFAULT_THEME);
+ $applicationThemePath = $this->getBaseDir('public/css/themes');
+ if (DirectoryIterator::isReadable($applicationThemePath)) {
+ foreach (new DirectoryIterator($applicationThemePath, 'less') as $name => $theme) {
+ $themes[] = substr($name, 0, -5);
+ }
+ }
+ $mm = $this->getModuleManager();
+ foreach ($mm->listEnabledModules() as $moduleName) {
+ $moduleThemePath = $mm->getModule($moduleName)->getCssDir() . '/themes';
+ if (! DirectoryIterator::isReadable($moduleThemePath)) {
+ continue;
+ }
+ foreach (new DirectoryIterator($moduleThemePath, 'less') as $name => $theme) {
+ $themes[] = $moduleName . '/' . substr($name, 0, -5);
+ }
+ }
+ return array_combine($themes, $themes);
+ }
+
+ /**
+ * Prepare routing
+ *
+ * @return $this
+ */
+ private function setupRoute()
+ {
+ $this->frontController->getRouter()->addRoute(
+ 'module_javascript',
+ new Zend_Controller_Router_Route(
+ 'js/components/:module_name/:file',
+ array(
+ 'controller' => 'static',
+ 'action' => 'javascript'
+ )
+ )
+ );
+
+ return $this;
+ }
+
+ /**
+ * Getter for frontController
+ *
+ * @return Zend_Controller_Front
+ */
+ public function getFrontController()
+ {
+ return $this->frontController;
+ }
+
+ /**
+ * Getter for view
+ *
+ * @return View
+ */
+ public function getViewRenderer()
+ {
+ return $this->viewRenderer;
+ }
+
+ private function hasAccessToSharedNavigationItem(&$config, Config $navConfig)
+ {
+ // TODO: Provide a more sophisticated solution
+
+ if (isset($config['owner']) && strtolower($config['owner']) === strtolower($this->user->getUsername())) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+
+ if (isset($config['parent']) && $navConfig->hasSection($config['parent'])) {
+ unset($config['owner']);
+ if (isset($this->accessibleMenuItems[$config['parent']])) {
+ return $this->accessibleMenuItems[$config['parent']];
+ }
+
+ $parentConfig = $navConfig->getSection($config['parent']);
+ $this->accessibleMenuItems[$config['parent']] = $this->hasAccessToSharedNavigationItem(
+ $parentConfig,
+ $navConfig
+ );
+ return $this->accessibleMenuItems[$config['parent']];
+ }
+
+ if (isset($config['users'])) {
+ $users = array_map('trim', explode(',', strtolower($config['users'])));
+ if (in_array('*', $users, true) || in_array(strtolower($this->user->getUsername()), $users, true)) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+ }
+
+ if (isset($config['groups'])) {
+ $groups = array_map('trim', explode(',', strtolower($config['groups'])));
+ if (in_array('*', $groups, true)) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+
+ $userGroups = array_map('strtolower', $this->user->getGroups());
+ $matches = array_intersect($userGroups, $groups);
+ if (! empty($matches)) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Load and return the shared navigation of the given type
+ *
+ * @param string $type
+ *
+ * @return Navigation
+ */
+ public function getSharedNavigation($type)
+ {
+ $config = Config::navigation($type === 'dashboard-pane' ? 'dashlet' : $type);
+
+ if ($type === 'dashboard-pane') {
+ $panes = array();
+ foreach ($config as $dashletName => $dashletConfig) {
+ if ($this->hasAccessToSharedNavigationItem($dashletConfig, $config)) {
+ // TODO: Throw ConfigurationError if pane or url is missing
+ $panes[$dashletConfig->pane][$dashletName] = $dashletConfig->url;
+ }
+ }
+
+ $navigation = new Navigation();
+ foreach ($panes as $paneName => $dashlets) {
+ $navigation->addItem(
+ $paneName,
+ array(
+ 'type' => 'dashboard-pane',
+ 'dashlets' => $dashlets
+ )
+ );
+ }
+ } else {
+ $items = array();
+ foreach ($config as $name => $typeConfig) {
+ if (isset($this->accessibleMenuItems[$name])) {
+ if ($this->accessibleMenuItems[$name]) {
+ $items[$name] = $typeConfig;
+ }
+ } else {
+ if ($this->hasAccessToSharedNavigationItem($typeConfig, $config)) {
+ $this->accessibleMenuItems[$name] = true;
+ $items[$name] = $typeConfig;
+ } else {
+ $this->accessibleMenuItems[$name] = false;
+ }
+ }
+ }
+
+ $navigation = Navigation::fromConfig($items);
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Dispatch public interface
+ */
+ public function dispatch()
+ {
+ $this->frontController->dispatch($this->getRequest(), $this->getResponse());
+ }
+
+ /**
+ * Prepare Zend MVC Base
+ *
+ * @return $this
+ */
+ private function setupZendMvc()
+ {
+ Zend_Layout::startMvc(
+ array(
+ 'layout' => 'layout',
+ 'layoutPath' => $this->getApplicationDir('/layouts/scripts')
+ )
+ );
+
+ $this->setupFrontController();
+ $this->setupViewRenderer();
+ return $this;
+ }
+
+ /**
+ * Create user object
+ *
+ * @return $this
+ */
+ private function setupUser()
+ {
+ $auth = Auth::getInstance();
+ if (! $this->request->isXmlHttpRequest() && $this->request->isApiRequest() && ! $auth->isAuthenticated()) {
+ $auth->authHttp();
+ }
+ if ($auth->isAuthenticated()) {
+ $user = $auth->getUser();
+ $this->getRequest()->setUser($user);
+ $this->user = $user;
+
+ if ($user->can('user/application/stacktraces')) {
+ $displayExceptions = $this->user->getPreferences()->getValue(
+ 'icingaweb',
+ 'show_stacktraces'
+ );
+
+ if ($displayExceptions !== null) {
+ $this->frontController->setParams(
+ array(
+ 'displayExceptions' => $displayExceptions
+ )
+ );
+ }
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Initialize a session provider
+ *
+ * @return $this
+ */
+ private function setupSession()
+ {
+ $this->session = Session::create();
+ return $this;
+ }
+
+ /**
+ * Initialize notifications to remove them immediately from session
+ *
+ * @return $this
+ */
+ private function setupNotifications()
+ {
+ Notification::getInstance();
+ return $this;
+ }
+
+ /**
+ * Instantiate front controller
+ *
+ * @return $this
+ */
+ private function setupFrontController()
+ {
+ $this->frontController = Zend_Controller_Front::getInstance();
+ $this->frontController->setDispatcher(new Dispatcher());
+ $this->frontController->setRequest($this->getRequest());
+ $this->frontController->setControllerDirectory($this->getApplicationDir('/controllers'));
+
+ $displayExceptions = $this->config->get('global', 'show_stacktraces', true);
+
+ $this->frontController->setParams(
+ array(
+ 'displayExceptions' => $displayExceptions
+ )
+ );
+ return $this;
+ }
+
+ /**
+ * Register helper paths and views for renderer
+ *
+ * @return $this
+ */
+ private function setupViewRenderer()
+ {
+ $view = Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer');
+ /** @var \Zend_Controller_Action_Helper_ViewRenderer $view */
+ $view->setView(new View());
+ $view->view->addHelperPath($this->getApplicationDir('/views/helpers'));
+ $view->view->setEncoding('UTF-8');
+ $view->view->headTitle()->prepend($this->config->get('global', 'project', 'Icinga'));
+ $view->view->headTitle()->setSeparator(' :: ');
+ $this->viewRenderer = $view;
+ return $this;
+ }
+
+ /**
+ * Configure pagination settings
+ *
+ * @return $this
+ */
+ private function setupPagination()
+ {
+ // TODO: document what we need for whatever reason?!
+ Zend_Paginator::addScrollingStylePrefixPath(
+ 'Icinga_Web_Paginator_ScrollingStyle_',
+ $this->getLibraryDir('Icinga/Web/Paginator/ScrollingStyle')
+ );
+
+ Zend_Paginator::addScrollingStylePrefixPath(
+ 'Icinga_Web_Paginator_ScrollingStyle',
+ 'Icinga/Web/Paginator/ScrollingStyle'
+ );
+
+ Zend_Paginator::setDefaultScrollingStyle('SlidingWithBorder');
+ Zend_View_Helper_PaginationControl::setDefaultViewPartial(
+ array('mixedPagination.phtml', 'default')
+ );
+ return $this;
+ }
+
+ /**
+ * Fatal error handling configuration
+ *
+ * @return $this
+ */
+ protected function setupFatalErrorHandling()
+ {
+ register_shutdown_function(function () {
+ $error = error_get_last();
+
+ if ($error !== null && $error['type'] === E_ERROR) {
+ $frontController = Icinga::app()->getFrontController();
+ $response = $frontController->getResponse();
+
+ $response->setException(new ErrorException(
+ $error['message'],
+ 0,
+ $error['type'],
+ $error['file'],
+ $error['line']
+ ));
+
+ // Clean PHP's fatal error stack trace and replace it with ours
+ ob_end_clean();
+ $frontController->dispatch($frontController->getRequest(), $response);
+ }
+ });
+
+ return $this;
+ }
+
+ /**
+ * (non-PHPDoc)
+ * @see ApplicationBootstrap::detectTimezone() For the method documentation.
+ */
+ protected function detectTimezone()
+ {
+ $auth = Auth::getInstance();
+ if (! $auth->isAuthenticated()
+ || ($timezone = $auth->getUser()->getPreferences()->getValue('icingaweb', 'timezone')) === null
+ ) {
+ $detect = new TimezoneDetect();
+ $timezone = $detect->getTimezoneName();
+ }
+ return $timezone;
+ }
+
+ /**
+ * Setup internationalization using gettext
+ *
+ * Uses the preferred user language or the browser suggested language or our default.
+ *
+ * @return string Detected locale code
+ */
+ protected function detectLocale()
+ {
+ $auth = Auth::getInstance();
+ if ($auth->isAuthenticated()
+ && ($locale = $auth->getUser()->getPreferences()->getValue('icingaweb', 'language')) !== null
+ ) {
+ return $locale;
+ }
+
+ /** @var GettextTranslator $translator */
+ $translator = StaticTranslator::$instance;
+
+ if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
+ return (new Locale())->getPreferred($_SERVER['HTTP_ACCEPT_LANGUAGE'], $translator->listLocales());
+ }
+
+ return $translator->getDefaultLocale();
+ }
+}
diff --git a/library/Icinga/Application/functions.php b/library/Icinga/Application/functions.php
new file mode 100644
index 0000000..12736fb
--- /dev/null
+++ b/library/Icinga/Application/functions.php
@@ -0,0 +1,110 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+use ipl\Stdlib\Contract\Translator;
+use ipl\I18n\StaticTranslator;
+
+/**
+ * No-op translate
+ *
+ * Supposed to be used for marking a string as available for translation without actually translating it immediately.
+ * The returned string is the one given in the input. This does only work with the standard gettext macros t() and mt().
+ *
+ * @param string $messageId
+ *
+ * @return string
+ */
+function N_(string $messageId): string
+{
+ return $messageId;
+}
+
+// Workaround for test issues, this is required unless our tests are able to
+// accomplish "real" bootstrapping
+if (function_exists('t')) {
+ return;
+}
+
+if (extension_loaded('gettext')) {
+
+ /**
+ * @see Translator::translate() For the function documentation.
+ */
+ function t(string $messageId, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translate($messageId, $context);
+ }
+
+ /**
+ * @see Translator::translateInDomain() For the function documentation.
+ */
+ function mt(string $domain, string $messageId, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translateInDomain($domain, $messageId, $context);
+ }
+
+ /**
+ * @see Translator::translatePlural() For the function documentation.
+ */
+ function tp(string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translatePlural($messageId, $messageId2, $number ?? 0, $context);
+ }
+
+ /**
+ * @see Translator::translatePluralInDomain() For the function documentation.
+ */
+ function mtp(string $domain, string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translatePluralInDomain(
+ $domain,
+ $messageId,
+ $messageId2,
+ $number ?? 0,
+ $context
+ );
+ }
+
+} else {
+
+ /**
+ * @see Translator::translate() For the function documentation.
+ */
+ function t(string $messageId, ?string $context = null): string
+ {
+ return $messageId;
+ }
+
+ /**
+ * @see Translator::translate() For the function documentation.
+ */
+ function mt(string $domain, string $messageId, ?string $context = null): string
+ {
+ return $messageId;
+ }
+
+ /**
+ * @see Translator::translatePlural() For the function documentation.
+ */
+ function tp(string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ if ((int) $number !== 1) {
+ return $messageId2;
+ }
+
+ return $messageId;
+ }
+
+ /**
+ * @see Translator::translatePlural() For the function documentation.
+ */
+ function mtp(string $domain, string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ if ((int) $number !== 1) {
+ return $messageId2;
+ }
+
+ return $messageId;
+ }
+
+}
diff --git a/library/Icinga/Application/webrouter.php b/library/Icinga/Application/webrouter.php
new file mode 100644
index 0000000..d9ab30b
--- /dev/null
+++ b/library/Icinga/Application/webrouter.php
@@ -0,0 +1,106 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Chart\Inline\PieChart;
+use Icinga\Web\Controller\StaticController;
+use Icinga\Web\JavaScript;
+use Icinga\Web\StyleSheet;
+
+error_reporting(E_ALL | E_STRICT);
+
+if (isset($_SERVER['REQUEST_URI'])) {
+ $ruri = $_SERVER['REQUEST_URI'];
+} else {
+ return false;
+}
+
+// Workaround, PHPs internal Webserver seems to mess up SCRIPT_FILENAME
+// as it prefixes it's absolute path with DOCUMENT_ROOT
+if (preg_match('/^PHP .* Development Server/', $_SERVER['SERVER_SOFTWARE'])) {
+ $script = basename($_SERVER['SCRIPT_FILENAME']);
+ $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'] = '/' . $script;
+ $_SERVER['SCRIPT_FILENAME'] = $_SERVER['DOCUMENT_ROOT']
+ . DIRECTORY_SEPARATOR
+ . $script;
+}
+
+$baseDir = $_SERVER['DOCUMENT_ROOT'];
+$baseDir = dirname($_SERVER['SCRIPT_FILENAME']);
+
+// Fix aliases
+$remove = str_replace('\\', '/', dirname($_SERVER['PHP_SELF']));
+if (substr($ruri, 0, strlen($remove)) !== $remove) {
+ return false;
+}
+$ruri = ltrim(substr($ruri, strlen($remove)), '/');
+
+if (strpos($ruri, '?') === false) {
+ $params = '';
+ $path = $ruri;
+} else {
+ list($path, $params) = preg_split('/\?/', $ruri, 2);
+}
+
+$special = array(
+ 'css/icinga.css',
+ 'css/icinga.min.css',
+ 'js/icinga.dev.js',
+ 'js/icinga.min.js'
+);
+
+if (in_array($path, $special)) {
+ include_once __DIR__ . '/EmbeddedWeb.php';
+ EmbeddedWeb::start();
+
+ switch ($path) {
+ case 'css/icinga.css':
+ Stylesheet::send();
+ exit;
+ case 'css/icinga.min.css':
+ Stylesheet::send(true);
+ exit;
+
+ case 'js/icinga.dev.js':
+ JavaScript::send();
+ exit;
+
+ case 'js/icinga.min.js':
+ JavaScript::sendMinified();
+ break;
+
+ default:
+ return false;
+ }
+} elseif ($path === 'svg/chart.php') {
+ if (!array_key_exists('data', $_GET)) {
+ return false;
+ }
+ include __DIR__ . '/EmbeddedWeb.php';
+ EmbeddedWeb::start();
+ header('Content-Type: image/svg+xml');
+ $pie = new PieChart();
+ $pie->initFromRequest();
+ $pie->toSvg();
+} elseif ($path === 'png/chart.php') {
+ if (!array_key_exists('data', $_GET)) {
+ return false;
+ }
+ include __DIR__ . '/EmbeddedWeb.php';
+ EmbeddedWeb::start();
+ header('Content-Type: image/png');
+ $pie = new PieChart();
+ $pie->initFromRequest();
+ $pie->toPng();
+} elseif (substr($path, 0, 4) === 'lib/') {
+ include_once __DIR__ . '/StaticWeb.php';
+ $app = StaticWeb::start();
+ (new StaticController())->handle($app->getRequest());
+ $app->getResponse()->sendResponse();
+} elseif (file_exists($baseDir . '/' . $path) && is_file($baseDir . '/' . $path)) {
+ return false;
+} else {
+ include __DIR__ . '/Web.php';
+ Web::start()->dispatch();
+}
diff --git a/library/Icinga/Authentication/AdmissionLoader.php b/library/Icinga/Authentication/AdmissionLoader.php
new file mode 100644
index 0000000..0c3fd3f
--- /dev/null
+++ b/library/Icinga/Authentication/AdmissionLoader.php
@@ -0,0 +1,249 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Generator;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Data\ConfigObject;
+use Icinga\User;
+use Icinga\Util\StringHelper;
+
+/**
+ * Retrieve restrictions and permissions for users
+ */
+class AdmissionLoader
+{
+ const LEGACY_PERMISSIONS = [
+ 'admin' => 'application/announcements',
+ 'application/stacktraces' => 'user/application/stacktraces',
+ 'application/share/navigation' => 'user/share/navigation',
+ // Migrating config/application/* would include config/modules, so that's skipped
+ //'config/application/*' => 'config/*',
+ 'config/application/general' => 'config/general',
+ 'config/application/resources' => 'config/resources',
+ 'config/application/navigation' => 'config/navigation',
+ 'config/application/userbackend' => 'config/access-control/users',
+ 'config/application/usergroupbackend' => 'config/access-control/groups',
+ 'config/authentication/*' => 'config/access-control/*',
+ 'config/authentication/users/*' => 'config/access-control/users',
+ 'config/authentication/users/show' => 'config/access-control/users',
+ 'config/authentication/users/add' => 'config/access-control/users',
+ 'config/authentication/users/edit' => 'config/access-control/users',
+ 'config/authentication/users/remove' => 'config/access-control/users',
+ 'config/authentication/groups/*' => 'config/access-control/groups',
+ 'config/authentication/groups/show' => 'config/access-control/groups',
+ 'config/authentication/groups/edit' => 'config/access-control/groups',
+ 'config/authentication/groups/add' => 'config/access-control/groups',
+ 'config/authentication/groups/remove' => 'config/access-control/groups',
+ 'config/authentication/roles/*' => 'config/access-control/roles',
+ 'config/authentication/roles/show' => 'config/access-control/roles',
+ 'config/authentication/roles/add' => 'config/access-control/roles',
+ 'config/authentication/roles/edit' => 'config/access-control/roles',
+ 'config/authentication/roles/remove' => 'config/access-control/roles'
+ ];
+
+ /** @var Role[] */
+ protected $roles;
+
+ /** @var ConfigObject */
+ protected $roleConfig;
+
+ public function __construct()
+ {
+ try {
+ $this->roleConfig = Config::app('roles');
+ } catch (NotReadableError $e) {
+ Logger::error('Can\'t access roles configuration. An exception was thrown:', $e);
+ }
+ }
+
+ /**
+ * Whether the user or groups are a member of the role
+ *
+ * @param string $username
+ * @param array $userGroups
+ * @param ConfigObject $section
+ *
+ * @return bool
+ */
+ protected function match($username, $userGroups, ConfigObject $section)
+ {
+ $username = strtolower($username);
+ if (! empty($section->users)) {
+ $users = array_map('strtolower', StringHelper::trimSplit($section->users));
+ if (in_array('*', $users)) {
+ return true;
+ }
+
+ if (in_array($username, $users)) {
+ return true;
+ }
+ }
+
+ if (! empty($section->groups)) {
+ $groups = array_map('strtolower', StringHelper::trimSplit($section->groups));
+ foreach ($userGroups as $userGroup) {
+ if (in_array(strtolower($userGroup), $groups)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Process role configuration and yield resulting roles
+ *
+ * This will also resolve any parent-child relationships.
+ *
+ * @param string $name
+ * @param ConfigObject $section
+ *
+ * @return Generator
+ * @throws ConfigurationError
+ */
+ protected function loadRole($name, ConfigObject $section)
+ {
+ if (! isset($this->roles[$name])) {
+ $permissions = $section->permissions ? StringHelper::trimSplit($section->permissions) : [];
+ $refusals = $section->refusals ? StringHelper::trimSplit($section->refusals) : [];
+
+ list($permissions, $newRefusals) = self::migrateLegacyPermissions($permissions);
+ if (! empty($newRefusals)) {
+ array_push($refusals, ...$newRefusals);
+ }
+
+ $restrictions = $section->toArray();
+ unset($restrictions['users'], $restrictions['groups']);
+ unset($restrictions['parent'], $restrictions['unrestricted']);
+ unset($restrictions['refusals'], $restrictions['permissions']);
+
+ $role = new Role();
+ $this->roles[$name] = $role
+ ->setName($name)
+ ->setRefusals($refusals)
+ ->setPermissions($permissions)
+ ->setRestrictions($restrictions)
+ ->setIsUnrestricted($section->get('unrestricted', false));
+
+ if (isset($section->parent)) {
+ $parentName = $section->parent;
+ if (! $this->roleConfig->hasSection($parentName)) {
+ Logger::error(
+ 'Failed to parse authentication configuration: Missing parent role "%s" (required by "%s")',
+ $parentName,
+ $name
+ );
+ throw new ConfigurationError(
+ t('Unable to parse authentication configuration. Check the log for more details.')
+ );
+ }
+
+ foreach ($this->loadRole($parentName, $this->roleConfig->getSection($parentName)) as $parent) {
+ if ($parent->getName() === $parentName) {
+ $role->setParent($parent);
+ $parent->addChild($role);
+
+ // Only yield main role once fully assembled
+ yield $role;
+ }
+
+ yield $parent;
+ }
+ } else {
+ yield $role;
+ }
+ } else {
+ yield $this->roles[$name];
+ }
+ }
+
+ /**
+ * Apply permissions, restrictions and roles to the given user
+ *
+ * @param User $user
+ */
+ public function applyRoles(User $user)
+ {
+ if ($this->roleConfig === null) {
+ return;
+ }
+
+ $username = $user->getUsername();
+ $userGroups = $user->getGroups();
+
+ $roles = [];
+ $permissions = [];
+ $restrictions = [];
+ $assignedRoles = [];
+ $isUnrestricted = false;
+ foreach ($this->roleConfig as $roleName => $roleConfig) {
+ $assigned = $this->match($username, $userGroups, $roleConfig);
+ if ($assigned) {
+ $assignedRoles[] = $roleName;
+ }
+
+ if (! isset($roles[$roleName]) && $assigned) {
+ foreach ($this->loadRole($roleName, $roleConfig) as $role) {
+ /** @var Role $role */
+ if (isset($roles[$role->getName()])) {
+ continue;
+ }
+
+ $roles[$role->getName()] = $role;
+
+ $permissions = array_merge(
+ $permissions,
+ array_diff($role->getPermissions(), $permissions)
+ );
+
+ $roleRestrictions = $role->getRestrictions();
+ foreach ($roleRestrictions as $name => & $restriction) {
+ $restriction = str_replace(
+ '$user.local_name$',
+ $user->getLocalUsername(),
+ $restriction
+ );
+ $restrictions[$name][] = $restriction;
+ }
+
+ $role->setRestrictions($roleRestrictions);
+
+ if (! $isUnrestricted) {
+ $isUnrestricted = $role->isUnrestricted();
+ }
+ }
+ }
+ }
+
+ $user->setAdditional('assigned_roles', $assignedRoles);
+
+ $user->setIsUnrestricted($isUnrestricted);
+ $user->setRestrictions($isUnrestricted ? [] : $restrictions);
+ $user->setPermissions($permissions);
+ $user->setRoles(array_values($roles));
+ }
+
+ public static function migrateLegacyPermissions(array $permissions)
+ {
+ $migratedGrants = [];
+ $refusals = [];
+
+ foreach ($permissions as $permission) {
+ if (array_key_exists($permission, self::LEGACY_PERMISSIONS)) {
+ $migratedGrants[] = self::LEGACY_PERMISSIONS[$permission];
+ } elseif ($permission === 'no-user/password-change') {
+ $refusals[] = 'user/password-change';
+ } else {
+ $migratedGrants[] = $permission;
+ }
+ }
+
+ return [$migratedGrants, $refusals];
+ }
+}
diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php
new file mode 100644
index 0000000..f358eac
--- /dev/null
+++ b/library/Icinga/Authentication/Auth.php
@@ -0,0 +1,453 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Hook\AuditHook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Authentication\User\ExternalBackend;
+use Icinga\Authentication\UserGroup\UserGroupBackend;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotReadableError;
+use Icinga\User;
+use Icinga\User\Preferences;
+use Icinga\User\Preferences\PreferencesStore;
+use Icinga\Web\Session;
+use Icinga\Web\StyleSheet;
+
+class Auth
+{
+ /**
+ * Singleton instance
+ *
+ * @var self
+ */
+ private static $instance;
+
+ /**
+ * Request
+ *
+ * @var \Icinga\Web\Request
+ */
+ protected $request;
+
+ /**
+ * Response
+ *
+ * @var \Icinga\Web\Response
+ */
+ protected $response;
+
+ /**
+ * Authenticated user
+ *
+ * @var User|null
+ */
+ private $user;
+
+
+ /**
+ * @see getInstance()
+ */
+ private function __construct()
+ {
+ }
+
+ /**
+ * Get the authentication manager
+ *
+ * @return self
+ */
+ public static function getInstance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Get the auth chain
+ *
+ * @return AuthChain
+ */
+ public function getAuthChain()
+ {
+ return new AuthChain();
+ }
+
+ /**
+ * Get whether the user is authenticated
+ *
+ * @return bool
+ */
+ public function isAuthenticated()
+ {
+ if ($this->user !== null) {
+ return true;
+ }
+ $this->authenticateFromSession();
+ if ($this->user === null && ! $this->authExternal()) {
+ return false;
+ }
+ return true;
+ }
+
+ public function setAuthenticated(User $user, $persist = true)
+ {
+ $this->setupUser($user);
+
+ // Reload CSS if the theme changed
+ $themingConfig = Icinga::app()->getConfig()->getSection('themes');
+ $userTheme = $user->getPreferences()->getValue('icingaweb', 'theme');
+ if (! (bool) $themingConfig->get('disabled', false) && $userTheme !== null) {
+ $defaultTheme = $themingConfig->get('default', StyleSheet::DEFAULT_THEME);
+ if ($userTheme !== $defaultTheme) {
+ $this->getResponse()->setReloadCss(true);
+ }
+ }
+
+ // Also reload CSS if the theme mode changed
+ $themeMode = $user->getPreferences()->getValue('icingaweb', 'theme_mode');
+ if ($themeMode && $themeMode !== StyleSheet::DEFAULT_MODE) {
+ $this->getResponse()->setReloadCss(true);
+ }
+
+ // Reload entire layout if the locale changed
+ if (($locale = $user->getPreferences()->getValue('icingaweb', 'language')) !== null) {
+ if (setlocale(LC_ALL, 0) !== $locale && $this->getRequest()->isXmlHttpRequest()) {
+ $this->getResponse()->setHeader('X-Icinga-Redirect-Http', 'yes');
+ }
+ }
+
+ $this->user = $user;
+ if ($persist) {
+ $this->persistCurrentUser();
+ }
+
+ AuditHook::logActivity('login', 'User logged in');
+ }
+
+ /**
+ * Getter for groups belonged to authenticated user
+ *
+ * @return array
+ * @see User::getGroups
+ */
+ public function getGroups()
+ {
+ return $this->user->getGroups();
+ }
+
+ /**
+ * Get the request
+ *
+ * @return \Icinga\Web\Request
+ */
+ public function getRequest()
+ {
+ if ($this->request === null) {
+ $this->request = Icinga::app()->getRequest();
+ }
+ return $this->request;
+ }
+
+ /**
+ * Get the response
+ *
+ * @return \Icinga\Web\Response
+ */
+ public function getResponse()
+ {
+ if ($this->response === null) {
+ $this->response = Icinga::app()->getResponse();
+ }
+ return $this->response;
+ }
+
+ /**
+ * Get applied restrictions matching a given restriction name
+ *
+ * Returns a list of applied restrictions, empty if no user is
+ * authenticated
+ *
+ * @param string $restriction Restriction name
+ * @return array
+ */
+ public function getRestrictions($restriction)
+ {
+ if (! $this->isAuthenticated()) {
+ return array();
+ }
+ return $this->user->getRestrictions($restriction);
+ }
+
+ /**
+ * Returns the current user or null if no user is authenticated
+ *
+ * @return User|null
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Set the authenticated user
+ *
+ * Note that this method just sets the authenticated user and thus bypasses our default authentication process in
+ * {@link setAuthenticated()}.
+ *
+ * @param User $user
+ *
+ * @return $this
+ */
+ public function setUser(User $user)
+ {
+ $this->user = $user;
+
+ return $this;
+ }
+
+ /**
+ * Try to authenticate the user with the current session
+ *
+ * Authentication for externally-authenticated users will be revoked if the username changed or external
+ * authentication is no longer in effect
+ */
+ public function authenticateFromSession()
+ {
+ $this->user = Session::getSession()->get('user');
+ if ($this->user !== null && $this->user->isExternalUser()) {
+ list($originUsername, $field) = $this->user->getExternalUserInformation();
+ $username = ExternalBackend::getRemoteUser($field);
+ if ($username === null || $username !== $originUsername) {
+ $this->removeAuthorization();
+ }
+ }
+ }
+
+ /**
+ * Attempt to authenticate a user from external user backends
+ *
+ * @return bool
+ */
+ protected function authExternal()
+ {
+ $user = new User('');
+ foreach ($this->getAuthChain() as $userBackend) {
+ if ($userBackend instanceof ExternalBackend) {
+ if ($userBackend->authenticate($user)) {
+ if (! $user->hasDomain()) {
+ $user->setDomain(Config::app()->get('authentication', 'default_domain'));
+ }
+ $this->setAuthenticated($user);
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Attempt to authenticate a user using HTTP authentication on API requests only
+ *
+ * Supports only the Basic HTTP authentication scheme. XHR will be ignored.
+ *
+ * @return bool
+ */
+ public function authHttp()
+ {
+ $request = $this->getRequest();
+ $header = $request->getHeader('Authorization');
+ if (empty($header)) {
+ return false;
+ }
+ list($scheme) = explode(' ', $header, 2);
+ if ($scheme !== 'Basic') {
+ return false;
+ }
+ $authorization = substr($header, strlen('Basic '));
+ $credentials = base64_decode($authorization);
+ $credentials = array_filter(explode(':', $credentials, 2));
+ if (count($credentials) !== 2) {
+ // Deny empty username and/or password
+ return false;
+ }
+ $user = new User($credentials[0]);
+ if (! $user->hasDomain()) {
+ $user->setDomain(Config::app()->get('authentication', 'default_domain'));
+ }
+ $password = $credentials[1];
+ if ($this->getAuthChain()->setSkipExternalBackends(true)->authenticate($user, $password)) {
+ $this->setAuthenticated($user, false);
+ $user->setIsHttpUser(true);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Challenge client immediately for HTTP authentication
+ *
+ * Sends the response w/ the 401 Unauthorized status code and WWW-Authenticate header.
+ */
+ public function challengeHttp()
+ {
+ $response = $this->getResponse();
+ $response->setHttpResponseCode(401);
+ $response->setHeader('WWW-Authenticate', 'Basic realm="Icinga Web 2"');
+ $response->sendHeaders();
+ exit();
+ }
+
+ /**
+ * Whether an authenticated user has a given permission
+ *
+ * @param string $permission Permission name
+ *
+ * @return bool True if the user owns the given permission, false if not or if not authenticated
+ */
+ public function hasPermission($permission)
+ {
+ if (! $this->isAuthenticated()) {
+ return false;
+ }
+ return $this->user->can($permission);
+ }
+
+ /**
+ * Writes the current user to the session
+ */
+ public function persistCurrentUser()
+ {
+ // @TODO(el): https://dev.icinga.com/issues/10646
+ $params = session_get_cookie_params();
+ setcookie(
+ 'icingaweb2-session',
+ time(),
+ 0,
+ $params['path'],
+ $params['domain'],
+ $params['secure'],
+ $params['httponly']
+ );
+ Session::getSession()->set('user', $this->user)->refreshId();
+ }
+
+ /**
+ * Purges the current authorization information and session
+ */
+ public function removeAuthorization()
+ {
+ AuditHook::logActivity('logout', 'User logged out');
+ $this->user = null;
+ Session::getSession()->purge();
+ }
+
+ /**
+ * Setup the given user
+ *
+ * This loads preferences, groups and roles.
+ *
+ * @param User $user
+ *
+ * @return void
+ */
+ public function setupUser(User $user)
+ {
+ // Load the user's preferences
+
+ try {
+ $config = Config::app();
+ } catch (NotReadableError $e) {
+ Logger::error(
+ new IcingaException(
+ 'Cannot load preferences for user "%s". An exception was thrown: %s',
+ $user->getUsername(),
+ $e
+ )
+ );
+ $config = new Config();
+ }
+
+ $preferencesConfig = new ConfigObject([
+ 'resource' => $config->get('global', 'config_resource')
+ ]);
+
+ try {
+ $preferencesStore = PreferencesStore::create($preferencesConfig, $user);
+ $preferences = new Preferences($preferencesStore->load());
+ } catch (Exception $e) {
+ Logger::error(
+ new IcingaException(
+ 'Cannot load preferences for user "%s". An exception was thrown: %s',
+ $user->getUsername(),
+ $e
+ )
+ );
+ $preferences = new Preferences();
+ }
+
+ $user->setPreferences($preferences);
+
+ // Load the user's groups
+ $groups = $user->getGroups();
+ $userBackendName = $user->getAdditional('backend_name');
+ foreach (Config::app('groups') as $name => $config) {
+ $groupsUserBackend = $config->user_backend;
+ if ($groupsUserBackend
+ && $groupsUserBackend !== 'none'
+ && $userBackendName !== null
+ && $groupsUserBackend !== $userBackendName
+ ) {
+ // Do not ask for Group membership if a specific User Backend
+ // has been assigned to that Group Backend, and the user has
+ // been authenticated by another User Backend
+ continue;
+ }
+
+ try {
+ $groupBackend = UserGroupBackend::create($name, $config);
+ $groupsFromBackend = $groupBackend->getMemberships($user);
+ } catch (Exception $e) {
+ Logger::error(
+ 'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown: %s',
+ $user->getUsername(),
+ $name,
+ $e
+ );
+ continue;
+ }
+
+ if (empty($groupsFromBackend)) {
+ Logger::debug(
+ 'No groups found in backend "%s" which the user "%s" is a member of.',
+ $name,
+ $user->getUsername()
+ );
+ continue;
+ }
+
+ $groupsFromBackend = array_values($groupsFromBackend);
+ Logger::debug(
+ 'Groups found in backend "%s" for user "%s": %s',
+ $name,
+ $user->getUsername(),
+ join(', ', $groupsFromBackend)
+ );
+ $groups = array_merge($groups, array_combine($groupsFromBackend, $groupsFromBackend));
+ }
+
+ $user->setGroups($groups);
+
+ // Load the user's roles
+ $admissionLoader = new AdmissionLoader();
+ $admissionLoader->applyRoles($user);
+ }
+}
diff --git a/library/Icinga/Authentication/AuthChain.php b/library/Icinga/Authentication/AuthChain.php
new file mode 100644
index 0000000..39468e3
--- /dev/null
+++ b/library/Icinga/Authentication/AuthChain.php
@@ -0,0 +1,269 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Icinga\Application\Hook\AuditHook;
+use Iterator;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Authentication\User\ExternalBackend;
+use Icinga\Authentication\User\UserBackend;
+use Icinga\Authentication\User\UserBackendInterface;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\User;
+
+/**
+ * Iterate user backends created from config
+ */
+class AuthChain implements Authenticatable, Iterator
+{
+ /**
+ * Authentication config file
+ *
+ * @var string
+ */
+ const AUTHENTICATION_CONFIG = 'authentication';
+
+ /**
+ * Error code if the authentication configuration was not readable
+ *
+ * @var int
+ */
+ const EPERM = 1;
+
+ /**
+ * Error code if the authentication configuration is empty
+ */
+ const EEMPTY = 2;
+
+ /**
+ * Error code if all authentication methods failed
+ *
+ * @var int
+ */
+ const EFAIL = 3;
+
+ /**
+ * Error code if not all authentication methods were available
+ *
+ * @var int
+ */
+ const ENOTALL = 4;
+
+ /**
+ * User backends configuration
+ *
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * The consecutive user backend while looping
+ *
+ * @var UserBackendInterface
+ */
+ protected $currentBackend;
+
+ /**
+ * Last error code
+ *
+ * @var int|null
+ */
+ protected $error;
+
+ /**
+ * Whether external user backends should be skipped on iteration
+ *
+ * @var bool
+ */
+ protected $skipExternalBackends = false;
+
+ /**
+ * Create a new authentication chain from config
+ *
+ * @param Config $config User backends configuration
+ */
+ public function __construct(Config $config = null)
+ {
+ if ($config === null) {
+ try {
+ $this->config = Config::app(static::AUTHENTICATION_CONFIG);
+ } catch (NotReadableError $e) {
+ $this->config = new Config();
+ $this->error = static::EPERM;
+ }
+ } else {
+ $this->config = $config;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function authenticate(User $user, $password)
+ {
+ $this->error = null;
+ $backendsTried = 0;
+ $backendsWithError = 0;
+ foreach ($this as $backend) {
+ ++$backendsTried;
+ try {
+ $authenticated = $backend->authenticate($user, $password);
+ } catch (AuthenticationException $e) {
+ Logger::error($e);
+ ++$backendsWithError;
+ continue;
+ }
+ if ($authenticated) {
+ $user->setAdditional('backend_name', $backend->getName());
+ $user->setAdditional('backend_type', $this->config->current()->get('backend'));
+ return true;
+ }
+ }
+
+ if ($backendsTried === 0) {
+ $this->error = static::EEMPTY;
+ } elseif ($backendsTried === $backendsWithError) {
+ $this->error = static::EFAIL;
+ } elseif ($backendsWithError) {
+ $this->error = static::ENOTALL;
+ } else {
+ AuditHook::logActivity('login-failed', 'User failed to authenticate', null, $user->getUsername());
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the last error code
+ *
+ * @return int|null
+ */
+ public function getError()
+ {
+ return $this->error;
+ }
+
+ /**
+ * Whether authentication had errors
+ *
+ * @return bool
+ */
+ public function hasError()
+ {
+ return $this->error !== null;
+ }
+
+ /**
+ * Get whether to skip external user backends on iteration
+ *
+ * @return bool
+ */
+ public function getSkipExternalBackends()
+ {
+ return $this->skipExternalBackends;
+ }
+
+ /**
+ * Set whether to skip external user backends on iteration
+ *
+ * @param bool $skipExternalBackends
+ *
+ * @return $this
+ */
+ public function setSkipExternalBackends($skipExternalBackends = true)
+ {
+ $this->skipExternalBackends = (bool) $skipExternalBackends;
+ return $this;
+ }
+
+ /**
+ * Rewind the chain
+ *
+ * @return void
+ */
+ public function rewind(): void
+ {
+ $this->currentBackend = null;
+ $this->config->rewind();
+ }
+
+ /**
+ * Get the current user backend
+ *
+ * @return UserBackendInterface
+ */
+ public function current(): UserBackendInterface
+ {
+ return $this->currentBackend;
+ }
+
+ /**
+ * Get the key of the current user backend config
+ *
+ * @return string
+ */
+ public function key(): string
+ {
+ return $this->config->key();
+ }
+
+ /**
+ * Move forward to the next user backend config
+ *
+ * @return void
+ */
+ public function next(): void
+ {
+ $this->config->next();
+ }
+
+ /**
+ * Check whether the current user backend is valid, i.e. it's enabled, not an external user backend and whether its
+ * config is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ if (! $this->config->valid()) {
+ // Stop when there are no more backends to check
+ return false;
+ }
+
+ $backendConfig = $this->config->current();
+ if ((bool) $backendConfig->get('disabled', false)) {
+ $this->next();
+ return $this->valid();
+ }
+
+ $name = $this->key();
+ try {
+ $backend = UserBackend::create($name, $backendConfig);
+ } catch (ConfigurationError $e) {
+ Logger::error(
+ new ConfigurationError(
+ 'Can\'t create authentication backend "%s". An exception was thrown:',
+ $name,
+ $e
+ )
+ );
+ $this->next();
+ return $this->valid();
+ }
+
+ if ($this->getSkipExternalBackends()
+ && $backend instanceof ExternalBackend
+ ) {
+ $this->next();
+ return $this->valid();
+ }
+
+ $this->currentBackend = $backend;
+ return true;
+ }
+}
diff --git a/library/Icinga/Authentication/Authenticatable.php b/library/Icinga/Authentication/Authenticatable.php
new file mode 100644
index 0000000..c10d6d3
--- /dev/null
+++ b/library/Icinga/Authentication/Authenticatable.php
@@ -0,0 +1,21 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Icinga\User;
+
+interface Authenticatable
+{
+ /**
+ * Authenticate a user
+ *
+ * @param User $user
+ * @param string $password
+ *
+ * @return bool
+ *
+ * @throws \Icinga\Exception\AuthenticationException If authentication errors
+ */
+ public function authenticate(User $user, $password);
+}
diff --git a/library/Icinga/Authentication/Role.php b/library/Icinga/Authentication/Role.php
new file mode 100644
index 0000000..c409ba4
--- /dev/null
+++ b/library/Icinga/Authentication/Role.php
@@ -0,0 +1,334 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+class Role
+{
+ /**
+ * Name of the role
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The role from which to inherit privileges
+ *
+ * @var Role
+ */
+ protected $parent;
+
+ /**
+ * The roles to which privileges are inherited
+ *
+ * @var Role[]
+ */
+ protected $children;
+
+ /**
+ * Whether restrictions should not apply to owners of the role
+ *
+ * @var bool
+ */
+ protected $unrestricted = false;
+
+ /**
+ * Permissions of the role
+ *
+ * @var string[]
+ */
+ protected $permissions = [];
+
+ /**
+ * Refusals of the role
+ *
+ * @var string[]
+ */
+ protected $refusals = [];
+
+ /**
+ * Restrictions of the role
+ *
+ * @var string[]
+ */
+ protected $restrictions = [];
+
+ /**
+ * Get the name of the role
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of the role
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the role from which privileges are inherited
+ *
+ * @return Role
+ */
+ public function getParent()
+ {
+ return $this->parent;
+ }
+
+ /**
+ * Set the role from which to inherit privileges
+ *
+ * @param Role $parent
+ *
+ * @return $this
+ */
+ public function setParent(Role $parent)
+ {
+ $this->parent = $parent;
+
+ return $this;
+ }
+
+ /**
+ * Get the roles to which privileges are inherited
+ *
+ * @return Role[]
+ */
+ public function getChildren()
+ {
+ return $this->children;
+ }
+
+ /**
+ * Set the roles to which inherit privileges
+ *
+ * @param Role[] $children
+ *
+ * @return $this
+ */
+ public function setChildren(array $children)
+ {
+ $this->children = $children;
+
+ return $this;
+ }
+
+ /**
+ * Add a role to which inherit privileges
+ *
+ * @param Role $role
+ *
+ * @return $this
+ */
+ public function addChild(Role $role)
+ {
+ $this->children[] = $role;
+
+ return $this;
+ }
+
+ /**
+ * Get whether restrictions should not apply to owners of the role
+ *
+ * @return bool
+ */
+ public function isUnrestricted()
+ {
+ return $this->unrestricted;
+ }
+
+ /**
+ * Set whether restrictions should not apply to owners of the role
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setIsUnrestricted($state)
+ {
+ $this->unrestricted = (bool) $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the permissions of the role
+ *
+ * @return string[]
+ */
+ public function getPermissions()
+ {
+ return $this->permissions;
+ }
+
+ /**
+ * Set the permissions of the role
+ *
+ * @param string[] $permissions
+ *
+ * @return $this
+ */
+ public function setPermissions(array $permissions)
+ {
+ $this->permissions = $permissions;
+
+ return $this;
+ }
+
+ /**
+ * Get the refusals of the role
+ *
+ * @return string[]
+ */
+ public function getRefusals()
+ {
+ return $this->refusals;
+ }
+
+ /**
+ * Set the refusals of the role
+ *
+ * @param array $refusals
+ *
+ * @return $this
+ */
+ public function setRefusals(array $refusals)
+ {
+ $this->refusals = $refusals;
+
+ return $this;
+ }
+
+ /**
+ * Get the restrictions of the role
+ *
+ * @param string $name Optional name of the restriction
+ *
+ * @return string[]|null
+ */
+ public function getRestrictions($name = null)
+ {
+ $restrictions = $this->restrictions;
+
+ if ($name === null) {
+ return $restrictions;
+ }
+
+ if (isset($restrictions[$name])) {
+ return $restrictions[$name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Set the restrictions of the role
+ *
+ * @param string[] $restrictions
+ *
+ * @return $this
+ */
+ public function setRestrictions(array $restrictions)
+ {
+ $this->restrictions = $restrictions;
+
+ return $this;
+ }
+
+ /**
+ * Whether this role grants the given permission
+ *
+ * @param string $permission
+ * @param bool $ignoreParent Only evaluate the role's own permissions
+ * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*`
+ *
+ * @return bool
+ */
+ public function grants($permission, $ignoreParent = false, $cascadeUpwards = true)
+ {
+ foreach ($this->permissions as $grantedPermission) {
+ if ($this->match($grantedPermission, $permission, $cascadeUpwards)) {
+ return true;
+ }
+ }
+
+ if (! $ignoreParent && $this->getParent() !== null) {
+ return $this->getParent()->grants($permission, false, $cascadeUpwards);
+ }
+
+ return false;
+ }
+
+ /**
+ * Whether this role denies the given permission
+ *
+ * @param string $permission
+ * @param bool $ignoreParent Only evaluate the role's own refusals
+ *
+ * @return bool
+ */
+ public function denies($permission, $ignoreParent = false)
+ {
+ foreach ($this->refusals as $refusedPermission) {
+ if ($this->match($refusedPermission, $permission, false)) {
+ return true;
+ }
+ }
+
+ if (! $ignoreParent && $this->getParent() !== null) {
+ return $this->getParent()->denies($permission);
+ }
+
+ return false;
+ }
+
+ /**
+ * Get whether the role expression matches the required permission
+ *
+ * @param string $roleExpression
+ * @param string $requiredPermission
+ * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*`
+ *
+ * @return bool
+ */
+ protected function match($roleExpression, $requiredPermission, $cascadeUpwards = true)
+ {
+ if ($roleExpression === '*' || $roleExpression === $requiredPermission) {
+ return true;
+ }
+
+ $requiredWildcard = strpos($requiredPermission, '*');
+ if ($requiredWildcard !== false) {
+ if (($grantedWildcard = strpos($roleExpression, '*')) !== false) {
+ $wildcard = $cascadeUpwards ? min($requiredWildcard, $grantedWildcard) : $grantedWildcard;
+ } else {
+ $wildcard = $cascadeUpwards ? $requiredWildcard : false;
+ }
+ } else {
+ $wildcard = strpos($roleExpression, '*');
+ }
+
+ if ($wildcard !== false && $wildcard > 0) {
+ if (substr($requiredPermission, 0, $wildcard) === substr($roleExpression, 0, $wildcard)) {
+ return true;
+ }
+ } elseif ($requiredPermission === $roleExpression) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Icinga/Authentication/RolesConfig.php b/library/Icinga/Authentication/RolesConfig.php
new file mode 100644
index 0000000..ac5695f
--- /dev/null
+++ b/library/Icinga/Authentication/RolesConfig.php
@@ -0,0 +1,43 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Icinga\Application\Icinga;
+use Icinga\Repository\IniRepository;
+
+class RolesConfig extends IniRepository
+{
+ protected $configs = [
+ 'roles' => [
+ 'name' => 'roles',
+ 'keyColumn' => 'name'
+ ]
+ ];
+
+ protected function initializeQueryColumns()
+ {
+ $columns = [
+ 'roles' => [
+ 'parent',
+ 'name',
+ 'users',
+ 'groups',
+ 'refusals',
+ 'permissions',
+ 'unrestricted',
+ 'application/share/users',
+ 'application/share/groups'
+ ]
+ ];
+
+ $moduleManager = Icinga::app()->getModuleManager();
+ foreach ($moduleManager->listInstalledModules() as $moduleName) {
+ foreach ($moduleManager->getModule($moduleName, false)->getProvidedRestrictions() as $restriction) {
+ $columns['roles'][] = $restriction->name;
+ }
+ }
+
+ return $columns;
+ }
+}
diff --git a/library/Icinga/Authentication/User/DbUserBackend.php b/library/Icinga/Authentication/User/DbUserBackend.php
new file mode 100644
index 0000000..0e8cc6a
--- /dev/null
+++ b/library/Icinga/Authentication/User/DbUserBackend.php
@@ -0,0 +1,256 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Exception;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Repository\DbRepository;
+use Icinga\User;
+use PDO;
+
+class DbUserBackend extends DbRepository implements UserBackendInterface, Inspectable
+{
+ /**
+ * The query columns being provided
+ *
+ * @var array
+ */
+ protected $queryColumns = array(
+ 'user' => array(
+ 'user' => 'name COLLATE utf8mb4_general_ci',
+ 'user_name' => 'name',
+ 'is_active' => 'active',
+ 'created_at' => 'UNIX_TIMESTAMP(ctime)',
+ 'last_modified' => 'UNIX_TIMESTAMP(mtime)'
+ )
+ );
+
+ /**
+ * The statement columns being provided
+ *
+ * @var array
+ */
+ protected $statementColumns = array(
+ 'user' => array(
+ 'password' => 'password_hash',
+ 'created_at' => 'ctime',
+ 'last_modified' => 'mtime'
+ )
+ );
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('user');
+
+ /**
+ * The default sort rules to be applied on a query
+ *
+ * @var array
+ */
+ protected $sortRules = array(
+ 'user_name' => array(
+ 'columns' => array(
+ 'is_active desc',
+ 'user_name'
+ )
+ )
+ );
+
+ /**
+ * The value conversion rules to apply on a query or statement
+ *
+ * @var array
+ */
+ protected $conversionRules = array(
+ 'user' => array(
+ 'password'
+ )
+ );
+
+ /**
+ * Initialize this database user backend
+ */
+ protected function init()
+ {
+ if (! $this->ds->getTablePrefix()) {
+ $this->ds->setTablePrefix('icingaweb_');
+ }
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ $userLabel = t('Username') . ' ' . t('(Case insensitive)');
+ return array(
+ $userLabel => 'user',
+ t('Username') => 'user_name',
+ t('Active') => 'is_active',
+ t('Created at') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Insert a table row with the given data
+ *
+ * @param string $table
+ * @param array $bind
+ *
+ * @return void
+ */
+ public function insert($table, array $bind, array $types = array())
+ {
+ $this->requireTable($table);
+ $bind['created_at'] = date('Y-m-d H:i:s');
+ $this->ds->insert(
+ $this->prependTablePrefix($table),
+ $this->requireStatementColumns($table, $bind),
+ array(
+ 'active' => PDO::PARAM_INT,
+ 'password_hash' => PDO::PARAM_LOB
+ )
+ );
+ }
+
+ /**
+ * Update table rows with the given data, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param array $bind
+ * @param Filter $filter
+ */
+ public function update($table, array $bind, Filter $filter = null, array $types = array())
+ {
+ $this->requireTable($table);
+ $bind['last_modified'] = date('Y-m-d H:i:s');
+ if ($filter) {
+ $filter = $this->requireFilter($table, $filter);
+ }
+
+ $this->ds->update(
+ $this->prependTablePrefix($table),
+ $this->requireStatementColumns($table, $bind),
+ $filter,
+ array(
+ 'active' => PDO::PARAM_INT,
+ 'password_hash' => PDO::PARAM_LOB
+ )
+ );
+ }
+
+ /**
+ * Hash and return the given password
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ protected function persistPassword($value)
+ {
+ return password_hash($value, PASSWORD_DEFAULT);
+ }
+
+ /**
+ * Fetch the hashed password for the given user
+ *
+ * @param string $username The name of the user
+ *
+ * @return string
+ */
+ protected function getPasswordHash($username)
+ {
+ if ($this->ds->getDbType() === 'pgsql') {
+ // Since PostgreSQL version 9.0 the default value for bytea_output is 'hex' instead of 'escape'
+ $columns = array('password_hash' => 'ENCODE(password_hash, \'escape\')');
+ } else {
+ $columns = array('password_hash');
+ }
+
+ $nameColumn = 'name';
+ if ($this->ds->getDbType() === 'mysql') {
+ $username = strtolower($username);
+ $nameColumn = 'BINARY LOWER(name)';
+ }
+
+ $query = $this->ds->select()
+ ->from($this->prependTablePrefix('user'), $columns)
+ ->where($nameColumn, $username)
+ ->where('active', true);
+
+ $statement = $this->ds->getDbAdapter()->prepare($query->getSelectQuery());
+ $statement->execute();
+ $statement->bindColumn(1, $lob, PDO::PARAM_LOB);
+ $statement->fetch(PDO::FETCH_BOUND);
+ if (is_resource($lob)) {
+ $lob = stream_get_contents($lob);
+ }
+
+ if ($lob === null) {
+ return '';
+ }
+
+ return $this->ds->getDbType() === 'pgsql' ? pg_unescape_bytea($lob) : $lob;
+ }
+
+ /**
+ * Authenticate the given user
+ *
+ * @param User $user
+ * @param string $password
+ *
+ * @return bool True on success, false on failure
+ *
+ * @throws AuthenticationException In case authentication is not possible due to an error
+ */
+ public function authenticate(User $user, $password)
+ {
+ try {
+ return password_verify(
+ $password,
+ $this->getPasswordHash($user->getUsername())
+ );
+ } catch (Exception $e) {
+ throw new AuthenticationException(
+ 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:',
+ $user->getUsername(),
+ $this->getName(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Inspect this object to gain extended information about its health
+ *
+ * @return Inspection The inspection result
+ */
+ public function inspect()
+ {
+ $insp = new Inspection('Db User Backend');
+ $insp->write($this->ds->inspect());
+ try {
+ $insp->write(sprintf('%s active users', $this->select()->where('is_active', true)->count()));
+ } catch (Exception $e) {
+ $insp->error(sprintf('Query failed: %s', $e->getMessage()));
+ }
+ return $insp;
+ }
+}
diff --git a/library/Icinga/Authentication/User/DomainAwareInterface.php b/library/Icinga/Authentication/User/DomainAwareInterface.php
new file mode 100644
index 0000000..3ff9c31
--- /dev/null
+++ b/library/Icinga/Authentication/User/DomainAwareInterface.php
@@ -0,0 +1,17 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+/**
+ * Interface for user backends that are responsible for a specific domain
+ */
+interface DomainAwareInterface
+{
+ /**
+ * Get the domain the backend is responsible for
+ *
+ * @return string
+ */
+ public function getDomain();
+}
diff --git a/library/Icinga/Authentication/User/ExternalBackend.php b/library/Icinga/Authentication/User/ExternalBackend.php
new file mode 100644
index 0000000..6e79928
--- /dev/null
+++ b/library/Icinga/Authentication/User/ExternalBackend.php
@@ -0,0 +1,124 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\User;
+
+/**
+ * Test login with external authentication mechanism, e.g. Apache
+ */
+class ExternalBackend implements UserBackendInterface
+{
+ /**
+ * Possible variables where to read the user from
+ *
+ * @var string[]
+ */
+ public static $remoteUserEnvvars = array('REMOTE_USER', 'REDIRECT_REMOTE_USER');
+
+ /**
+ * The name of this backend
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * Regexp expression to strip values from a username
+ *
+ * @var string
+ */
+ protected $stripUsernameRegexp;
+
+ /**
+ * Create new authentication backend of type "external"
+ *
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->stripUsernameRegexp = $config->get('strip_username_regexp');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Get the remote user from environment or $_SERVER, if any
+ *
+ * @param string $variable The name of the variable where to read the user from
+ *
+ * @return string|null
+ */
+ public static function getRemoteUser($variable = 'REMOTE_USER')
+ {
+ $username = getenv($variable);
+ if (! empty($username)) {
+ return $username;
+ }
+
+ if (array_key_exists($variable, $_SERVER) && ! empty($_SERVER[$variable])) {
+ return $_SERVER[$variable];
+ }
+ }
+
+ /**
+ * Get the remote user information from environment or $_SERVER, if any
+ *
+ * @return array Contains always two entries, the username and origin which may both set to null.
+ */
+ public static function getRemoteUserInformation()
+ {
+ foreach (static::$remoteUserEnvvars as $envVar) {
+ $username = static::getRemoteUser($envVar);
+ if ($username !== null) {
+ return array($username, $envVar);
+ }
+ }
+
+ return array(null, null);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function authenticate(User $user, $password = null)
+ {
+ list($username, $field) = static::getRemoteUserInformation();
+ if ($username !== null) {
+ $user->setExternalUserInformation($username, $field);
+
+ if ($this->stripUsernameRegexp) {
+ $stripped = @preg_replace($this->stripUsernameRegexp, '', $username);
+ if ($stripped === false) {
+ Logger::error('Failed to strip external username. The configured regular expression is invalid.');
+ return false;
+ }
+
+ $username = $stripped;
+ }
+
+ $user->setUsername($username);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Icinga/Authentication/User/LdapUserBackend.php b/library/Icinga/Authentication/User/LdapUserBackend.php
new file mode 100644
index 0000000..6a2cacf
--- /dev/null
+++ b/library/Icinga/Authentication/User/LdapUserBackend.php
@@ -0,0 +1,479 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use DateTime;
+use Exception;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\QueryException;
+use Icinga\Repository\LdapRepository;
+use Icinga\Repository\RepositoryQuery;
+use Icinga\Protocol\Ldap\LdapException;
+use Icinga\User;
+
+class LdapUserBackend extends LdapRepository implements UserBackendInterface, DomainAwareInterface, Inspectable
+{
+ /**
+ * The base DN to use for a query
+ *
+ * @var string
+ */
+ protected $baseDn;
+
+ /**
+ * The objectClass where look for users
+ *
+ * @var string
+ */
+ protected $userClass;
+
+ /**
+ * The attribute name where to find a user's name
+ *
+ * @var string
+ */
+ protected $userNameAttribute;
+
+ /**
+ * The custom LDAP filter to apply on search queries
+ *
+ * @var string
+ */
+ protected $filter;
+
+ /**
+ * The domain the backend is responsible for
+ *
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('user');
+
+ /**
+ * The default sort rules to be applied on a query
+ *
+ * @var array
+ */
+ protected $sortRules = array(
+ 'user_name' => array(
+ 'columns' => array(
+ 'is_active desc',
+ 'user_name'
+ )
+ )
+ );
+
+ /**
+ * Set the base DN to use for a query
+ *
+ * @param string $baseDn
+ *
+ * @return $this
+ */
+ public function setBaseDn($baseDn)
+ {
+ if ($baseDn && ($baseDn = trim($baseDn))) {
+ $this->baseDn = $baseDn;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the base DN to use for a query
+ *
+ * @return string
+ */
+ public function getBaseDn()
+ {
+ return $this->baseDn;
+ }
+
+ /**
+ * Set the objectClass where to look for users
+ *
+ * @param string $userClass
+ *
+ * @return $this
+ */
+ public function setUserClass($userClass)
+ {
+ $this->userClass = $this->getNormedAttribute($userClass);
+ return $this;
+ }
+
+ /**
+ * Return the objectClass where to look for users
+ *
+ * @return string
+ */
+ public function getUserClass()
+ {
+ return $this->userClass;
+ }
+
+ /**
+ * Set the attribute name where to find a user's name
+ *
+ * @param string $userNameAttribute
+ *
+ * @return $this
+ */
+ public function setUserNameAttribute($userNameAttribute)
+ {
+ $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a user's name
+ *
+ * @return string
+ */
+ public function getUserNameAttribute()
+ {
+ return $this->userNameAttribute;
+ }
+
+ /**
+ * Set the custom LDAP filter to apply on search queries
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setFilter($filter)
+ {
+ if ($filter && ($filter = trim($filter))) {
+ if ($filter[0] === '(') {
+ $filter = substr($filter, 1, -1);
+ }
+
+ $this->filter = $filter;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the custom LDAP filter to apply on search queries
+ *
+ * @return string
+ */
+ public function getFilter()
+ {
+ return $this->filter;
+ }
+
+ public function getDomain()
+ {
+ return $this->domain;
+ }
+
+ /**
+ * Set the domain the backend is responsible for
+ *
+ * @param string $domain
+ *
+ * @return $this
+ */
+ public function setDomain($domain)
+ {
+ if ($domain && ($domain = trim($domain))) {
+ $this->domain = $domain;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Initialize this repository's virtual tables
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case $this->userClass has not been set yet
+ */
+ protected function initializeVirtualTables()
+ {
+ if ($this->userClass === null) {
+ throw new ProgrammingError('It is required to set the object class where to find users first');
+ }
+
+ return array(
+ 'user' => $this->userClass
+ );
+ }
+
+ /**
+ * Initialize this repository's query columns
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case $this->userNameAttribute has not been set yet
+ */
+ protected function initializeQueryColumns()
+ {
+ if ($this->userNameAttribute === null) {
+ throw new ProgrammingError('It is required to set a attribute name where to find a user\'s name first');
+ }
+
+ if ($this->ds->getCapabilities()->isActiveDirectory()) {
+ $isActiveAttribute = 'userAccountControl';
+ $createdAtAttribute = 'whenCreated';
+ $lastModifiedAttribute = 'whenChanged';
+ } else {
+ // TODO(jom): Elaborate whether it is possible to add dynamic support for the ppolicy
+ $isActiveAttribute = 'shadowExpire';
+
+ $createdAtAttribute = 'createTimestamp';
+ $lastModifiedAttribute = 'modifyTimestamp';
+ }
+
+ return array(
+ 'user' => array(
+ 'user' => $this->userNameAttribute,
+ 'user_name' => $this->userNameAttribute,
+ 'is_active' => $isActiveAttribute,
+ 'created_at' => $createdAtAttribute,
+ 'last_modified' => $lastModifiedAttribute
+ )
+ );
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ return array(
+ t('Username') => 'user_name',
+ t('Active') => 'is_active',
+ t('Created At') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Initialize this repository's conversion rules
+ *
+ * @return array
+ */
+ protected function initializeConversionRules()
+ {
+ if ($this->ds->getCapabilities()->isActiveDirectory()) {
+ $stateConverter = 'user_account_control';
+ } else {
+ $stateConverter = 'shadow_expire';
+ }
+
+ return array(
+ 'user' => array(
+ 'is_active' => $stateConverter,
+ 'created_at' => 'generalized_time',
+ 'last_modified' => 'generalized_time'
+ )
+ );
+ }
+
+ /**
+ * Return whether the given userAccountControl value defines that a user is permitted to login
+ *
+ * @param string|null $value
+ *
+ * @return bool
+ */
+ protected function retrieveUserAccountControl($value)
+ {
+ if ($value === null) {
+ return $value;
+ }
+
+ $ADS_UF_ACCOUNTDISABLE = 2;
+ return ((int) $value & $ADS_UF_ACCOUNTDISABLE) === 0;
+ }
+
+ /**
+ * Return whether the given shadowExpire value defines that a user is permitted to login
+ *
+ * @param string|null $value
+ *
+ * @return bool
+ */
+ protected function retrieveShadowExpire($value)
+ {
+ if ($value === null) {
+ return $value;
+ }
+
+ $now = new DateTime();
+ $bigBang = clone $now;
+ $bigBang->setTimestamp(0);
+ return ((int) $value) >= $bigBang->diff($now)->days;
+ }
+
+ /**
+ * Validate that the requested table exists
+ *
+ * @param string $table The table to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case the given table does not exist
+ */
+ public function requireTable($table, RepositoryQuery $query = null)
+ {
+ if ($query !== null) {
+ $query->getQuery()->setBase($this->baseDn);
+ if ($this->filter) {
+ $query->getQuery()->setNativeFilter($this->filter);
+ }
+ }
+
+ return parent::requireTable($table, $query);
+ }
+
+ /**
+ * Validate that the given column is a valid query target and return it or the actual name if it's an alias
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid query column
+ */
+ public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
+ {
+ $column = parent::requireQueryColumn($table, $name, $query);
+ if ($name === 'user_name' && $query !== null) {
+ $query->getQuery()->setUnfoldAttribute('user_name');
+ }
+
+ return $column;
+ }
+
+ /**
+ * Authenticate the given user
+ *
+ * @param User $user
+ * @param string $password
+ *
+ * @return bool True on success, false on failure
+ *
+ * @throws AuthenticationException In case authentication is not possible due to an error
+ */
+ public function authenticate(User $user, $password)
+ {
+ if ($this->domain !== null) {
+ if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($this->domain)) {
+ return false;
+ }
+
+ $username = $user->getLocalUsername();
+ } else {
+ $username = $user->getUsername();
+ }
+
+ try {
+ $userDn = $this
+ ->select()
+ ->where('user_name', str_replace('*', '', $username))
+ ->getQuery()
+ ->setUsePagedResults(false)
+ ->fetchDn();
+ if ($userDn === null) {
+ return false;
+ }
+
+ $validCredentials = $this->ds->testCredentials($userDn, $password);
+ if ($validCredentials) {
+ $user->setAdditional('ldap_dn', $userDn);
+ }
+
+ return $validCredentials;
+ } catch (LdapException $e) {
+ throw new AuthenticationException(
+ 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:',
+ $username,
+ $this->getName(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Inspect if this LDAP User Backend is working as expected by probing the backend
+ * and testing if thea uthentication is possible
+ *
+ * Try to bind to the backend and fetch a single user to check if:
+ * <ul>
+ * <li>Connection credentials are correct and the bind is possible</li>
+ * <li>At least one user exists</li>
+ * <li>The specified userClass has the property specified by userNameAttribute</li>
+ * </ul>
+ *
+ * @return Inspection Inspection result
+ */
+ public function inspect()
+ {
+ $result = new Inspection('Ldap User Backend');
+
+ // inspect the used connection to get more diagnostic info in case the connection is not working
+ $result->write($this->ds->inspect());
+ try {
+ try {
+ $res = $this->select()->fetchRow();
+ } catch (LdapException $e) {
+ throw new AuthenticationException('Connection not possible', $e);
+ }
+ $result->write('Searching for: ' . sprintf(
+ 'objectClass "%s" in DN "%s" (Filter: %s)',
+ $this->userClass,
+ $this->baseDn ?: $this->ds->getDn(),
+ $this->filter ?: 'None'
+ ));
+ if ($res === false) {
+ throw new AuthenticationException('Error, no users found in backend');
+ }
+ $result->write(sprintf('%d users found in backend', $this->select()->count()));
+ if (! isset($res->user_name)) {
+ throw new AuthenticationException(
+ 'UserNameAttribute "%s" not existing in objectClass "%s"',
+ $this->userNameAttribute,
+ $this->userClass
+ );
+ }
+ } catch (AuthenticationException $e) {
+ if (($previous = $e->getPrevious()) !== null) {
+ $result->error($previous->getMessage());
+ } else {
+ $result->error($e->getMessage());
+ }
+ } catch (Exception $e) {
+ $result->error(sprintf('Unable to validate authentication: %s', $e->getMessage()));
+ }
+ return $result;
+ }
+}
diff --git a/library/Icinga/Authentication/User/UserBackend.php b/library/Icinga/Authentication/User/UserBackend.php
new file mode 100644
index 0000000..423b278
--- /dev/null
+++ b/library/Icinga/Authentication/User/UserBackend.php
@@ -0,0 +1,259 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Application\Icinga;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Util\ConfigAwareFactory;
+
+/**
+ * Factory for user backends
+ */
+class UserBackend implements ConfigAwareFactory
+{
+ /**
+ * The default user backend types provided by Icinga Web 2
+ *
+ * @var array
+ */
+ protected static $defaultBackends = array(
+ 'external',
+ 'db',
+ 'ldap',
+ 'msldap'
+ );
+
+ /**
+ * The registered custom user backends with their identifier as key and class name as value
+ *
+ * @var array
+ */
+ protected static $customBackends;
+
+ /**
+ * User backend configuration
+ *
+ * @var Config
+ */
+ private static $backends;
+
+ /**
+ * Set user backend configuration
+ *
+ * @param Config $config
+ */
+ public static function setConfig($config)
+ {
+ self::$backends = $config;
+ }
+
+ /**
+ * Return the configuration of all existing user backends
+ *
+ * @return Config
+ */
+ public static function getBackendConfigs()
+ {
+ self::assertBackendsExist();
+ return self::$backends;
+ }
+
+ /**
+ * Check if any user backends exist. If not, throw an error.
+ *
+ * @throws ConfigurationError
+ */
+ private static function assertBackendsExist()
+ {
+ if (self::$backends === null) {
+ throw new ConfigurationError(
+ 'User backends not set up. Please contact your Icinga Web administrator'
+ );
+ }
+ }
+
+ /**
+ * Register all custom user backends from all loaded modules
+ */
+ protected static function registerCustomUserBackends()
+ {
+ if (static::$customBackends !== null) {
+ return;
+ }
+
+ static::$customBackends = array();
+ $providedBy = array();
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ foreach ($module->getUserBackends() as $identifier => $className) {
+ if (array_key_exists($identifier, $providedBy)) {
+ Logger::warning(
+ 'Cannot register user backend of type "%s" provided by module "%s".'
+ . ' The type is already provided by module "%s"',
+ $identifier,
+ $module->getName(),
+ $providedBy[$identifier]
+ );
+ } elseif (in_array($identifier, static::$defaultBackends)) {
+ Logger::warning(
+ 'Cannot register user backend of type "%s" provided by module "%s".'
+ . ' The type is a default type provided by Icinga Web 2',
+ $identifier,
+ $module->getName()
+ );
+ } else {
+ $providedBy[$identifier] = $module->getName();
+ static::$customBackends[$identifier] = $className;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get config forms of all custom user backends
+ */
+ public static function getCustomBackendConfigForms()
+ {
+ $customBackendConfigForms = [];
+ static::registerCustomUserBackends();
+ foreach (self::$customBackends as $customBackendType => $customBackendClass) {
+ if (method_exists($customBackendClass, 'getConfigurationFormClass')) {
+ $customBackendConfigForms[$customBackendType] = $customBackendClass::getConfigurationFormClass();
+ }
+ }
+
+ return $customBackendConfigForms;
+ }
+
+ /**
+ * Return the class for the given custom user backend
+ *
+ * @param string $identifier The identifier of the custom user backend
+ *
+ * @return string|null The name of the class or null in case there was no
+ * backend found with the given identifier
+ *
+ * @throws ConfigurationError In case the class associated to the given identifier does not exist
+ */
+ protected static function getCustomUserBackend($identifier)
+ {
+ static::registerCustomUserBackends();
+ if (array_key_exists($identifier, static::$customBackends)) {
+ $className = static::$customBackends[$identifier];
+ if (! class_exists($className)) {
+ throw new ConfigurationError(
+ 'Cannot utilize user backend of type "%s". Class "%s" does not exist',
+ $identifier,
+ $className
+ );
+ }
+
+ return $className;
+ }
+ }
+
+ /**
+ * Create and return a user backend with the given name and given configuration applied to it
+ *
+ * @param string $name
+ * @param ConfigObject $backendConfig
+ *
+ * @return UserBackendInterface
+ *
+ * @throws ConfigurationError
+ */
+ public static function create($name, ConfigObject $backendConfig = null)
+ {
+ if ($backendConfig === null) {
+ self::assertBackendsExist();
+ if (self::$backends->hasSection($name)) {
+ $backendConfig = self::$backends->getSection($name);
+ } else {
+ throw new ConfigurationError('User backend "%s" does not exist', $name);
+ }
+ }
+
+ if ($backendConfig->name !== null) {
+ $name = $backendConfig->name;
+ }
+
+ if (! ($backendType = strtolower($backendConfig->backend))) {
+ throw new ConfigurationError(
+ 'Authentication configuration for user backend "%s" is missing the \'backend\' directive',
+ $name
+ );
+ }
+
+ if ($backendType === 'external') {
+ $backend = new ExternalBackend($backendConfig);
+ $backend->setName($name);
+ return $backend;
+ }
+ if (in_array($backendType, static::$defaultBackends)) {
+ // The default backend check is the first one because of performance reasons:
+ // Do not attempt to load a custom user backend unless it's actually required
+ } elseif (($customClass = static::getCustomUserBackend($backendType)) !== null) {
+ $backend = new $customClass($backendConfig);
+ if (! is_a($backend, 'Icinga\Authentication\User\UserBackendInterface')) {
+ throw new ConfigurationError(
+ 'Cannot utilize user backend of type "%s". Class "%s" does not implement UserBackendInterface',
+ $backendType,
+ $customClass
+ );
+ }
+
+ $backend->setName($name);
+ return $backend;
+ } else {
+ throw new ConfigurationError(
+ 'Authentication configuration for user backend "%s" defines an invalid backend type.'
+ . ' Backend type "%s" is not supported',
+ $name,
+ $backendType
+ );
+ }
+
+ if ($backendConfig->resource === null) {
+ throw new ConfigurationError(
+ 'Authentication configuration for user backend "%s" is missing the \'resource\' directive',
+ $name
+ );
+ }
+
+ $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource);
+ if ($backendType === 'db' && $resourceConfig->db === 'mysql') {
+ $resourceConfig->charset = 'utf8mb4';
+ }
+
+ $resource = ResourceFactory::createResource($resourceConfig);
+ $backend = null;
+ switch ($backendType) {
+ case 'db':
+ $backend = new DbUserBackend($resource);
+ break;
+ case 'msldap':
+ $backend = new LdapUserBackend($resource);
+ $backend->setBaseDn($backendConfig->base_dn);
+ $backend->setUserClass($backendConfig->get('user_class', 'user'));
+ $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'sAMAccountName'));
+ $backend->setFilter($backendConfig->filter);
+ $backend->setDomain($backendConfig->domain);
+ break;
+ case 'ldap':
+ $backend = new LdapUserBackend($resource);
+ $backend->setBaseDn($backendConfig->base_dn);
+ $backend->setUserClass($backendConfig->get('user_class', 'inetOrgPerson'));
+ $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'uid'));
+ $backend->setFilter($backendConfig->filter);
+ $backend->setDomain($backendConfig->domain);
+ break;
+ }
+
+ $backend->setName($name);
+ return $backend;
+ }
+}
diff --git a/library/Icinga/Authentication/User/UserBackendInterface.php b/library/Icinga/Authentication/User/UserBackendInterface.php
new file mode 100644
index 0000000..4660eb0
--- /dev/null
+++ b/library/Icinga/Authentication/User/UserBackendInterface.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Icinga\Authentication\Authenticatable;
+use Icinga\User;
+
+/**
+ * Interface for user backends
+ */
+interface UserBackendInterface extends Authenticatable
+{
+ /**
+ * Set this backend's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name);
+
+ /**
+ * Return this backend's name
+ *
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Return this backend's configuration form class path
+ *
+ * This is not part of the interface to not break existing implementations.
+ * If you need a custom backend form, implement this method.
+ *
+ * @return string
+ */
+ //public static function getConfigurationFormClass();
+}
diff --git a/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php
new file mode 100644
index 0000000..5299bbb
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php
@@ -0,0 +1,325 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Exception;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Exception\NotFoundError;
+use Icinga\Repository\DbRepository;
+use Icinga\Repository\RepositoryQuery;
+use Icinga\User;
+
+class DbUserGroupBackend extends DbRepository implements Inspectable, UserGroupBackendInterface
+{
+ /**
+ * The query columns being provided
+ *
+ * @var array
+ */
+ protected $queryColumns = array(
+ 'group' => array(
+ 'group_id' => 'g.id',
+ 'group' => 'g.name COLLATE utf8mb4_general_ci',
+ 'group_name' => 'g.name',
+ 'parent' => 'g.parent',
+ 'created_at' => 'UNIX_TIMESTAMP(g.ctime)',
+ 'last_modified' => 'UNIX_TIMESTAMP(g.mtime)'
+ ),
+ 'group_membership' => array(
+ 'group_id' => 'gm.group_id',
+ 'user' => 'gm.username COLLATE utf8mb4_general_ci',
+ 'user_name' => 'gm.username',
+ 'created_at' => 'UNIX_TIMESTAMP(gm.ctime)',
+ 'last_modified' => 'UNIX_TIMESTAMP(gm.mtime)'
+ )
+ );
+
+ /**
+ * The table aliases being applied
+ *
+ * @var array
+ */
+ protected $tableAliases = array(
+ 'group' => 'g',
+ 'group_membership' => 'gm'
+ );
+
+ /**
+ * The statement columns being provided
+ *
+ * @var array
+ */
+ protected $statementColumns = array(
+ 'group' => array(
+ 'group_id' => 'id',
+ 'group_name' => 'name',
+ 'parent' => 'parent',
+ 'created_at' => 'ctime',
+ 'last_modified' => 'mtime'
+ ),
+ 'group_membership' => array(
+ 'group_id' => 'group_id',
+ 'group_name' => 'group_id',
+ 'user_name' => 'username',
+ 'created_at' => 'ctime',
+ 'last_modified' => 'mtime'
+ )
+ );
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('group', 'user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('group', 'user');
+
+ /**
+ * The value conversion rules to apply on a query or statement
+ *
+ * @var array
+ */
+ protected $conversionRules = array(
+ 'group' => array(
+ 'parent' => 'group_id'
+ ),
+ 'group_membership' => array(
+ 'group_name' => 'group_id'
+ )
+ );
+
+ /**
+ * Initialize this database user group backend
+ */
+ protected function init()
+ {
+ if (! $this->ds->getTablePrefix()) {
+ $this->ds->setTablePrefix('icingaweb_');
+ }
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ $userLabel = t('Username') . ' ' . t('(Case insensitive)');
+ $groupLabel = t('User Group') . ' ' . t('(Case insensitive)');
+ return array(
+ $userLabel => 'user',
+ t('Username') => 'user_name',
+ $groupLabel => 'group',
+ t('User Group') => 'group_name',
+ t('Parent') => 'parent',
+ t('Created At') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Insert a table row with the given data
+ *
+ * @param string $table
+ * @param array $bind
+ */
+ public function insert($table, array $bind, array $types = array())
+ {
+ $bind['created_at'] = date('Y-m-d H:i:s');
+ parent::insert($table, $bind);
+ }
+
+ /**
+ * Update table rows with the given data, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param array $bind
+ * @param Filter $filter
+ */
+ public function update($table, array $bind, Filter $filter = null, array $types = array())
+ {
+ $bind['last_modified'] = date('Y-m-d H:i:s');
+ parent::update($table, $bind, $filter);
+ }
+
+ /**
+ * Delete table rows, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param Filter $filter
+ */
+ public function delete($table, Filter $filter = null)
+ {
+ if ($table === 'group') {
+ parent::delete('group_membership', $filter);
+ $idQuery = $this->select(array('group_id'));
+ if ($filter !== null) {
+ $idQuery->applyFilter($filter);
+ }
+
+ $this->update('group', array('parent' => null), Filter::where('parent', $idQuery->fetchColumn()));
+ }
+
+ parent::delete($table, $filter);
+ }
+
+ /**
+ * Return the groups the given user is a member of
+ *
+ * @param User $user
+ *
+ * @return array
+ */
+ public function getMemberships(User $user)
+ {
+ $groupQuery = $this->ds
+ ->select()
+ ->from(
+ array('g' => $this->prependTablePrefix('group')),
+ array(
+ 'group_name' => 'g.name',
+ 'parent_name' => 'gg.name'
+ )
+ )->joinLeft(
+ array('gg' => $this->prependTablePrefix('group')),
+ 'g.parent = gg.id',
+ array()
+ );
+
+ $groups = array();
+ foreach ($groupQuery as $group) {
+ $groups[$group->group_name] = $group->parent_name;
+ }
+
+ $membershipQuery = $this
+ ->select()
+ ->from('group_membership', array('group_name'))
+ ->where('user_name', $user->getUsername());
+
+ $memberships = array();
+ foreach ($membershipQuery as $membership) {
+ $memberships[] = $membership->group_name;
+ $parent = $groups[$membership->group_name];
+ while ($parent !== null) {
+ $memberships[] = $parent;
+ // Usually a parent is an existing group, but since we do not have a constraint on our table..
+ $parent = isset($groups[$parent]) ? $groups[$parent] : null;
+ }
+ }
+
+ return $memberships;
+ }
+
+ /**
+ * Return the name of the backend that is providing the given user
+ *
+ * @param string $username Currently unused
+ *
+ * @return null|string The name of the backend or null in case this information is not available
+ */
+ public function getUserBackendName($username)
+ {
+ return null; // TODO(10373): Store this to the database when inserting and fetch it here
+ }
+
+ /**
+ * Join group into group_membership
+ *
+ * @param RepositoryQuery $query
+ */
+ protected function joinGroup(RepositoryQuery $query)
+ {
+ $query->getQuery()->join(
+ $this->requireTable('group'),
+ 'gm.group_id = g.id',
+ array()
+ );
+ }
+
+ /**
+ * Join group_membership into group
+ *
+ * @param RepositoryQuery $query
+ */
+ protected function joinGroupMembership(RepositoryQuery $query)
+ {
+ $query->getQuery()->joinLeft(
+ $this->requireTable('group_membership'),
+ 'g.id = gm.group_id',
+ array()
+ )->group('g.id');
+ }
+
+ /**
+ * Fetch and return the corresponding id for the given group's name
+ *
+ * @param string|array $groupName
+ *
+ * @return int
+ *
+ * @throws NotFoundError
+ */
+ protected function persistGroupId($groupName)
+ {
+ if (empty($groupName) || is_numeric($groupName)) {
+ return $groupName;
+ }
+
+ if (is_array($groupName)) {
+ if (is_numeric($groupName[0])) {
+ return $groupName; // In case the array contains mixed types...
+ }
+
+ $groupIds = $this->ds
+ ->select()
+ ->from($this->prependTablePrefix('group'), array('id'))
+ ->where('name', $groupName)
+ ->fetchColumn();
+ if (empty($groupIds)) {
+ throw new NotFoundError('No groups found matching one of: %s', implode(', ', $groupName));
+ }
+
+ return $groupIds;
+ }
+
+ $groupId = $this->ds
+ ->select()
+ ->from($this->prependTablePrefix('group'), array('id'))
+ ->where('name', $groupName)
+ ->fetchOne();
+ if ($groupId === false) {
+ throw new NotFoundError('Group "%s" does not exist', $groupName);
+ }
+
+ return $groupId;
+ }
+
+ /**
+ * Inspect this object to gain extended information about its health
+ *
+ * @return Inspection The inspection result
+ */
+ public function inspect()
+ {
+ $insp = new Inspection('Db User Group Backend');
+ $insp->write($this->ds->inspect());
+
+ try {
+ $insp->write(sprintf('%s group(s)', $this->select()->count()));
+ } catch (Exception $e) {
+ $insp->error(sprintf('Query failed: %s', $e->getMessage()));
+ }
+
+ return $insp;
+ }
+}
diff --git a/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php
new file mode 100644
index 0000000..e78242e
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php
@@ -0,0 +1,945 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Exception;
+use Icinga\Authentication\User\UserBackend;
+use Icinga\Authentication\User\LdapUserBackend;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\QueryException;
+use Icinga\Protocol\Ldap\LdapException;
+use Icinga\Protocol\Ldap\LdapUtils;
+use Icinga\Repository\LdapRepository;
+use Icinga\Repository\RepositoryQuery;
+use Icinga\User;
+
+class LdapUserGroupBackend extends LdapRepository implements Inspectable, UserGroupBackendInterface
+{
+ /**
+ * The user backend being associated with this user group backend
+ *
+ * @var LdapUserBackend
+ */
+ protected $userBackend;
+
+ /**
+ * The base DN to use for a user query
+ *
+ * @var string
+ */
+ protected $userBaseDn;
+
+ /**
+ * The base DN to use for a group query
+ *
+ * @var string
+ */
+ protected $groupBaseDn;
+
+ /**
+ * The objectClass where look for users
+ *
+ * @var string
+ */
+ protected $userClass;
+
+ /**
+ * The objectClass where look for groups
+ *
+ * @var string
+ */
+ protected $groupClass;
+
+ /**
+ * The attribute name where to find a user's name
+ *
+ * @var string
+ */
+ protected $userNameAttribute;
+
+ /**
+ * The attribute name where to find a group's name
+ *
+ * @var string
+ */
+ protected $groupNameAttribute;
+
+ /**
+ * The attribute name where to find a group's member
+ *
+ * @var string
+ */
+ protected $groupMemberAttribute;
+
+ /**
+ * Whether the attribute name where to find a group's member holds ambiguous values
+ *
+ * @var bool
+ */
+ protected $ambiguousMemberAttribute;
+
+ /**
+ * The custom LDAP filter to apply on a user query
+ *
+ * @var string
+ */
+ protected $userFilter;
+
+ /**
+ * The custom LDAP filter to apply on a group query
+ *
+ * @var string
+ */
+ protected $groupFilter;
+
+ /**
+ * ActiveDirectory nested group on the user?
+ *
+ * @var bool
+ */
+ protected $nestedGroupSearch;
+
+ /**
+ * The domain the backend is responsible for
+ *
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('group', 'user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('group', 'user');
+
+ /**
+ * The default sort rules to be applied on a query
+ *
+ * @var array
+ */
+ protected $sortRules = array(
+ 'group_name' => array(
+ 'order' => 'asc'
+ )
+ );
+
+ /**
+ * Set the user backend to be associated with this user group backend
+ *
+ * @param LdapUserBackend $backend
+ *
+ * @return $this
+ */
+ public function setUserBackend(LdapUserBackend $backend)
+ {
+ $this->userBackend = $backend;
+ return $this;
+ }
+
+ /**
+ * Return the user backend being associated with this user group backend
+ *
+ * @return LdapUserBackend
+ */
+ public function getUserBackend()
+ {
+ return $this->userBackend;
+ }
+
+ /**
+ * Set the base DN to use for a user query
+ *
+ * @param string $baseDn
+ *
+ * @return $this
+ */
+ public function setUserBaseDn($baseDn)
+ {
+ if ($baseDn && ($baseDn = trim($baseDn))) {
+ $this->userBaseDn = $baseDn;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the base DN to use for a user query
+ *
+ * @return string
+ */
+ public function getUserBaseDn()
+ {
+ return $this->userBaseDn;
+ }
+
+ /**
+ * Set the base DN to use for a group query
+ *
+ * @param string $baseDn
+ *
+ * @return $this
+ */
+ public function setGroupBaseDn($baseDn)
+ {
+ if ($baseDn && ($baseDn = trim($baseDn))) {
+ $this->groupBaseDn = $baseDn;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the base DN to use for a group query
+ *
+ * @return string
+ */
+ public function getGroupBaseDn()
+ {
+ return $this->groupBaseDn;
+ }
+
+ /**
+ * Set the objectClass where to look for users
+ *
+ * @param string $userClass
+ *
+ * @return $this
+ */
+ public function setUserClass($userClass)
+ {
+ $this->userClass = $this->getNormedAttribute($userClass);
+ return $this;
+ }
+
+ /**
+ * Return the objectClass where to look for users
+ *
+ * @return string
+ */
+ public function getUserClass()
+ {
+ return $this->userClass;
+ }
+
+ /**
+ * Set the objectClass where to look for groups
+ *
+ * @param string $groupClass
+ *
+ * @return $this
+ */
+ public function setGroupClass($groupClass)
+ {
+ $this->groupClass = $this->getNormedAttribute($groupClass);
+ return $this;
+ }
+
+ /**
+ * Return the objectClass where to look for groups
+ *
+ * @return string
+ */
+ public function getGroupClass()
+ {
+ return $this->groupClass;
+ }
+
+ /**
+ * Set the attribute name where to find a user's name
+ *
+ * @param string $userNameAttribute
+ *
+ * @return $this
+ */
+ public function setUserNameAttribute($userNameAttribute)
+ {
+ $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a user's name
+ *
+ * @return string
+ */
+ public function getUserNameAttribute()
+ {
+ return $this->userNameAttribute;
+ }
+
+ /**
+ * Set the attribute name where to find a group's name
+ *
+ * @param string $groupNameAttribute
+ *
+ * @return $this
+ */
+ public function setGroupNameAttribute($groupNameAttribute)
+ {
+ $this->groupNameAttribute = $this->getNormedAttribute($groupNameAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a group's name
+ *
+ * @return string
+ */
+ public function getGroupNameAttribute()
+ {
+ return $this->groupNameAttribute;
+ }
+
+ /**
+ * Set the attribute name where to find a group's member
+ *
+ * @param string $groupMemberAttribute
+ *
+ * @return $this
+ */
+ public function setGroupMemberAttribute($groupMemberAttribute)
+ {
+ $this->groupMemberAttribute = $this->getNormedAttribute($groupMemberAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a group's member
+ *
+ * @return string
+ */
+ public function getGroupMemberAttribute()
+ {
+ return $this->groupMemberAttribute;
+ }
+
+ /**
+ * Set the custom LDAP filter to apply on a user query
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setUserFilter($filter)
+ {
+ if ($filter && ($filter = trim($filter))) {
+ if ($filter[0] === '(') {
+ $filter = substr($filter, 1, -1);
+ }
+
+ $this->userFilter = $filter;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the custom LDAP filter to apply on a user query
+ *
+ * @return string
+ */
+ public function getUserFilter()
+ {
+ return $this->userFilter;
+ }
+
+ /**
+ * Set the custom LDAP filter to apply on a group query
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setGroupFilter($filter)
+ {
+ if ($filter && ($filter = trim($filter))) {
+ $this->groupFilter = $filter;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the custom LDAP filter to apply on a group query
+ *
+ * @return string
+ */
+ public function getGroupFilter()
+ {
+ return $this->groupFilter;
+ }
+
+ /**
+ * Set nestedGroupSearch for the group query
+ *
+ * @param bool $enable
+ *
+ * @return $this
+ */
+ public function setNestedGroupSearch($enable = true)
+ {
+ $this->nestedGroupSearch = $enable;
+ return $this;
+ }
+
+ /**
+ * Get nestedGroupSearch for the group query
+ *
+ * @return bool
+ */
+ public function getNestedGroupSearch()
+ {
+ return $this->nestedGroupSearch;
+ }
+
+ /**
+ * Get the domain the backend is responsible for
+ *
+ * If the LDAP group backend is linked with a LDAP user backend,
+ * the domain of the user backend will be returned.
+ *
+ * @return string
+ */
+ public function getDomain()
+ {
+ return $this->userBackend !== null ? $this->userBackend->getDomain() : $this->domain;
+ }
+
+ /**
+ * Set the domain the backend is responsible for
+ *
+ * If the LDAP group backend is linked with a LDAP user backend,
+ * the domain of the user backend will be used nonetheless.
+ *
+ * @param string $domain
+ *
+ * @return $this
+ */
+ public function setDomain($domain)
+ {
+ if ($domain && ($domain = trim($domain))) {
+ $this->domain = $domain;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether the attribute name where to find a group's member holds ambiguous values
+ *
+ * This tries to detect if the member attribute of groups contain:
+ *
+ * full DN -> distinguished name of another object
+ * other -> ambiguous field referencing the member by userNameAttribute
+ *
+ * @return bool
+ *
+ * @throws ProgrammingError In case either $this->groupClass or $this->groupMemberAttribute
+ * has not been set yet
+ */
+ protected function isMemberAttributeAmbiguous()
+ {
+ if ($this->ambiguousMemberAttribute === null) {
+ if ($this->groupClass === null) {
+ throw new ProgrammingError(
+ 'It is required to set the objectClass where to look for groups first'
+ );
+ } elseif ($this->groupMemberAttribute === null) {
+ throw new ProgrammingError(
+ 'It is required to set a attribute name where to find a group\'s members first'
+ );
+ }
+
+ $sampleValues = $this->ds
+ ->select()
+ ->from($this->groupClass, array($this->groupMemberAttribute))
+ ->where($this->groupMemberAttribute, '*')
+ ->limit(Logger::getInstance()->getLevel() === Logger::DEBUG ? 3 : 1)
+ ->setUnfoldAttribute($this->groupMemberAttribute)
+ ->setBase($this->groupBaseDn)
+ ->fetchAll();
+
+ Logger::debug('Ambiguity query returned %d results', count($sampleValues));
+
+ $i = 0;
+ $sampleValue = null;
+ foreach ($sampleValues as $key => $value) {
+ if ($sampleValue === null) {
+ $sampleValue = $value;
+ }
+
+ Logger::debug('Result %d: %s (%s)', ++$i, $value, $key);
+ }
+
+ if (is_object($sampleValue) && isset($sampleValue->{$this->groupMemberAttribute})) {
+ $this->ambiguousMemberAttribute = ! LdapUtils::isDn($sampleValue->{$this->groupMemberAttribute});
+
+ Logger::debug(
+ 'Ambiguity check came to the conclusion that the member attribute %s ambiguous. Tested sample: %s',
+ $this->ambiguousMemberAttribute ? 'is' : 'is not',
+ $sampleValue->{$this->groupMemberAttribute}
+ );
+ } else {
+ Logger::warning(
+ 'Ambiguity query returned zero or invalid results. Sample value is `%s`',
+ print_r($sampleValue, true)
+ );
+ }
+ }
+
+ return $this->ambiguousMemberAttribute;
+ }
+
+ /**
+ * Initialize this repository's virtual tables
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case $this->groupClass has not been set yet
+ */
+ protected function initializeVirtualTables()
+ {
+ if ($this->groupClass === null) {
+ throw new ProgrammingError('It is required to set the object class where to find groups first');
+ }
+
+ return array(
+ 'group' => $this->groupClass,
+ 'group_membership' => $this->groupClass
+ );
+ }
+
+ /**
+ * Initialize this repository's query columns
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case either $this->groupNameAttribute or
+ * $this->groupMemberAttribute has not been set yet
+ */
+ protected function initializeQueryColumns()
+ {
+ if ($this->groupNameAttribute === null) {
+ throw new ProgrammingError('It is required to set a attribute name where to find a group\'s name first');
+ }
+ if ($this->groupMemberAttribute === null) {
+ throw new ProgrammingError('It is required to set a attribute name where to find a group\'s members first');
+ }
+
+ if ($this->ds->getCapabilities()->isActiveDirectory()) {
+ $createdAtAttribute = 'whenCreated';
+ $lastModifiedAttribute = 'whenChanged';
+ } else {
+ $createdAtAttribute = 'createTimestamp';
+ $lastModifiedAttribute = 'modifyTimestamp';
+ }
+
+ $columns = array(
+ 'group' => $this->groupNameAttribute,
+ 'group_name' => $this->groupNameAttribute,
+ 'user' => $this->groupMemberAttribute,
+ 'user_name' => $this->groupMemberAttribute,
+ 'created_at' => $createdAtAttribute,
+ 'last_modified' => $lastModifiedAttribute
+ );
+ return array('group' => $columns, 'group_membership' => $columns);
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ return array(
+ t('Username') => 'user_name',
+ t('User Group') => 'group_name',
+ t('Created At') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Initialize this repository's conversion rules
+ *
+ * @return array
+ */
+ protected function initializeConversionRules()
+ {
+ $rules = array(
+ 'group' => array(
+ 'created_at' => 'generalized_time',
+ 'last_modified' => 'generalized_time'
+ ),
+ 'group_membership' => array(
+ 'created_at' => 'generalized_time',
+ 'last_modified' => 'generalized_time'
+ )
+ );
+ if (! $this->isMemberAttributeAmbiguous()) {
+ $rules['group_membership']['user_name'] = 'user_name';
+ $rules['group_membership']['user'] = 'user_name';
+ $rules['group']['user_name'] = 'user_name';
+ $rules['group']['user'] = 'user_name';
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Return the distinguished name for the given uid or gid
+ *
+ * @param string $name
+ *
+ * @return string
+ */
+ protected function persistUserName($name)
+ {
+ try {
+ $userDn = $this->ds
+ ->select()
+ ->from($this->userClass, array())
+ ->where($this->userNameAttribute, $name)
+ ->setBase($this->userBaseDn)
+ ->setUsePagedResults(false)
+ ->fetchDn();
+ if ($userDn) {
+ return $userDn;
+ }
+
+ $groupDn = $this->ds
+ ->select()
+ ->from($this->groupClass, array())
+ ->where($this->groupNameAttribute, $name)
+ ->setBase($this->groupBaseDn)
+ ->setUsePagedResults(false)
+ ->fetchDn();
+ if ($groupDn) {
+ return $groupDn;
+ }
+ } catch (LdapException $_) {
+ // pass
+ }
+
+ Logger::debug('Unable to persist uid or gid "%s" in repository "%s". No DN found.', $name, $this->getName());
+ return $name;
+ }
+
+ /**
+ * Return the uid for the given distinguished name
+ *
+ * @param string $username
+ *
+ * @return string
+ */
+ protected function retrieveUserName($dn)
+ {
+ return $this->ds
+ ->select()
+ ->from('*', array($this->userNameAttribute))
+ ->setUnfoldAttribute($this->userNameAttribute)
+ ->setBase($dn)
+ ->fetchOne();
+ }
+
+ /**
+ * Validate that the requested table exists
+ *
+ * @param string $table The table to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case the given table does not exist
+ */
+ public function requireTable($table, RepositoryQuery $query = null)
+ {
+ if ($query !== null) {
+ $query->getQuery()->setBase($this->groupBaseDn);
+ if ($table === 'group' && $this->groupFilter) {
+ $query->getQuery()->setNativeFilter($this->groupFilter);
+ }
+ }
+
+ return parent::requireTable($table, $query);
+ }
+
+ /**
+ * Validate that the given column is a valid query target and return it or the actual name if it's an alias
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid query column
+ */
+ public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
+ {
+ $column = parent::requireQueryColumn($table, $name, $query);
+ if (($name === 'user_name' || $name === 'group_name') && $query !== null) {
+ $query->getQuery()->setUnfoldAttribute($name);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Return the groups the given user is a member of
+ *
+ * @param User $user
+ *
+ * @return array
+ */
+ public function getMemberships(User $user)
+ {
+ $domain = $this->getDomain();
+
+ if ($domain !== null) {
+ if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($domain)) {
+ return array();
+ }
+
+ $username = $user->getLocalUsername();
+ } else {
+ $username = $user->getUsername();
+ }
+
+ if ($this->isMemberAttributeAmbiguous()) {
+ $queryValue = $username;
+ } elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) {
+ $userQuery = $this->ds
+ ->select()
+ ->from($this->userClass)
+ ->where($this->userNameAttribute, $username)
+ ->setBase($this->userBaseDn)
+ ->setUsePagedResults(false);
+ if ($this->userFilter) {
+ $userQuery->setNativeFilter($this->userFilter);
+ }
+
+ if (($queryValue = $userQuery->fetchDn()) === null) {
+ return array();
+ }
+ }
+
+ if ($this->nestedGroupSearch) {
+ $groupMemberAttribute = $this->groupMemberAttribute . ':1.2.840.113556.1.4.1941:';
+ } else {
+ $groupMemberAttribute = $this->groupMemberAttribute;
+ }
+
+ $groupQuery = $this->ds
+ ->select()
+ ->from($this->groupClass, array($this->groupNameAttribute))
+ ->setUnfoldAttribute($this->groupNameAttribute)
+ ->where($groupMemberAttribute, $queryValue)
+ ->setBase($this->groupBaseDn);
+ if ($this->groupFilter) {
+ $groupQuery->setNativeFilter($this->groupFilter);
+ }
+
+ $groups = array();
+ foreach ($groupQuery as $row) {
+ $groups[] = $row->{$this->groupNameAttribute};
+ if ($domain !== null) {
+ $groups[] = $row->{$this->groupNameAttribute} . "@$domain";
+ }
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Return the name of the backend that is providing the given user
+ *
+ * @param string $username Unused
+ *
+ * @return null|string The name of the backend or null in case this information is not available
+ */
+ public function getUserBackendName($username)
+ {
+ $userBackend = $this->getUserBackend();
+ if ($userBackend !== null) {
+ return $userBackend->getName();
+ }
+ }
+
+ /**
+ * Apply the given configuration on this backend
+ *
+ * @param ConfigObject $config
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError In case a linked user backend does not exist or is invalid
+ */
+ public function setConfig(ConfigObject $config)
+ {
+ if ($config->backend === 'ldap') {
+ $defaults = $this->getOpenLdapDefaults();
+ } elseif ($config->backend === 'msldap') {
+ $defaults = $this->getActiveDirectoryDefaults();
+ } else {
+ $defaults = new ConfigObject();
+ }
+
+ if ($config->user_backend && $config->user_backend !== 'none') {
+ $userBackend = UserBackend::create($config->user_backend);
+ if (! $userBackend instanceof LdapUserBackend) {
+ throw new ConfigurationError('User backend "%s" is not of type LDAP', $config->user_backend);
+ }
+
+ if ($this->ds->getHostname() !== $userBackend->getDataSource()->getHostname()
+ || $this->ds->getPort() !== $userBackend->getDataSource()->getPort()
+ ) {
+ // TODO(jom): Elaborate whether it makes sense to link directories on different hosts
+ throw new ConfigurationError(
+ 'It is required that a linked user backend refers to the '
+ . 'same directory as it\'s user group backend counterpart'
+ );
+ }
+
+ $this->setUserBackend($userBackend);
+ $defaults->merge(array(
+ 'user_base_dn' => $userBackend->getBaseDn(),
+ 'user_class' => $userBackend->getUserClass(),
+ 'user_name_attribute' => $userBackend->getUserNameAttribute(),
+ 'user_filter' => $userBackend->getFilter(),
+ 'domain' => $userBackend->getDomain()
+ ));
+ }
+
+ return $this
+ ->setGroupBaseDn($config->base_dn)
+ ->setUserBaseDn($config->get('user_base_dn', $defaults->get('user_base_dn', $this->getGroupBaseDn())))
+ ->setGroupClass($config->get('group_class', $defaults->group_class))
+ ->setUserClass($config->get('user_class', $defaults->user_class))
+ ->setGroupNameAttribute($config->get('group_name_attribute', $defaults->group_name_attribute))
+ ->setUserNameAttribute($config->get('user_name_attribute', $defaults->user_name_attribute))
+ ->setGroupMemberAttribute($config->get('group_member_attribute', $defaults->group_member_attribute))
+ ->setGroupFilter($config->group_filter)
+ ->setUserFilter($config->user_filter)
+ ->setNestedGroupSearch((bool) $config->get('nested_group_search', $defaults->nested_group_search))
+ ->setDomain($defaults->get('domain', $config->domain));
+ }
+
+ /**
+ * Return the configuration defaults for an OpenLDAP environment
+ *
+ * @return ConfigObject
+ */
+ public function getOpenLdapDefaults()
+ {
+ return new ConfigObject(array(
+ 'group_class' => 'group',
+ 'user_class' => 'inetOrgPerson',
+ 'group_name_attribute' => 'gid',
+ 'user_name_attribute' => 'uid',
+ 'group_member_attribute' => 'member',
+ 'nested_group_search' => '0'
+ ));
+ }
+
+ /**
+ * Return the configuration defaults for an ActiveDirectory environment
+ *
+ * @return ConfigObject
+ */
+ public function getActiveDirectoryDefaults()
+ {
+ return new ConfigObject(array(
+ 'group_class' => 'group',
+ 'user_class' => 'user',
+ 'group_name_attribute' => 'sAMAccountName',
+ 'user_name_attribute' => 'sAMAccountName',
+ 'group_member_attribute' => 'member',
+ 'nested_group_search' => '0'
+ ));
+ }
+
+ /**
+ * Inspect if this LDAP User Group Backend is working as expected by probing the backend
+ *
+ * Try to bind to the backend and fetch a single group to check if:
+ * <ul>
+ * <li>Connection credentials are correct and the bind is possible</li>
+ * <li>At least one group exists</li>
+ * <li>The specified groupClass has the property specified by groupNameAttribute</li>
+ * </ul>
+ *
+ * @return Inspection Inspection result
+ */
+ public function inspect()
+ {
+ $result = new Inspection('Ldap User Group Backend');
+
+ // inspect the used connection to get more diagnostic info in case the connection is not working
+ $result->write($this->ds->inspect());
+
+ try {
+ try {
+ $groupQuery = $this->ds
+ ->select()
+ ->from($this->groupClass, array($this->groupNameAttribute))
+ ->setBase($this->groupBaseDn);
+
+ if ($this->groupFilter) {
+ $groupQuery->setNativeFilter($this->groupFilter);
+ }
+
+ $res = $groupQuery->fetchRow();
+ } catch (LdapException $e) {
+ throw new AuthenticationException('Connection not possible', $e);
+ }
+
+ $result->write('Searching for: ' . sprintf(
+ 'objectClass "%s" in DN "%s" (Filter: %s)',
+ $this->groupClass,
+ $this->groupBaseDn ?: $this->ds->getDn(),
+ $this->groupFilter ?: 'None'
+ ));
+
+ if ($res === false) {
+ throw new AuthenticationException('Error, no groups found in backend');
+ }
+
+ $result->write(sprintf('%d groups found in backend', $groupQuery->count()));
+
+ if (! isset($res->{$this->groupNameAttribute})) {
+ throw new AuthenticationException(
+ 'GroupNameAttribute "%s" not existing in objectClass "%s"',
+ $this->groupNameAttribute,
+ $this->groupClass
+ );
+ }
+ } catch (AuthenticationException $e) {
+ if (($previous = $e->getPrevious()) !== null) {
+ $result->error($previous->getMessage());
+ } else {
+ $result->error($e->getMessage());
+ }
+ } catch (Exception $e) {
+ $result->error(sprintf('Unable to validate backend: %s', $e->getMessage()));
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackend.php b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php
new file mode 100644
index 0000000..7f0bfcc
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php
@@ -0,0 +1,189 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Icinga\Application\Logger;
+use Icinga\Application\Icinga;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * Factory for user group backends
+ */
+class UserGroupBackend
+{
+ /**
+ * The default user group backend types provided by Icinga Web 2
+ *
+ * @var array
+ */
+ protected static $defaultBackends = array(
+ 'db',
+ 'ldap',
+ 'msldap'
+ );
+
+ /**
+ * The registered custom user group backends with their identifier as key and class name as value
+ *
+ * @var array
+ */
+ protected static $customBackends;
+
+ /**
+ * Register all custom user group backends from all loaded modules
+ */
+ public static function registerCustomUserGroupBackends()
+ {
+ if (static::$customBackends !== null) {
+ return;
+ }
+
+ static::$customBackends = array();
+ $providedBy = array();
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ foreach ($module->getUserGroupBackends() as $identifier => $className) {
+ if (array_key_exists($identifier, $providedBy)) {
+ Logger::warning(
+ 'Cannot register user group backend of type "%s" provided by module "%s".'
+ . ' The type is already provided by module "%s"',
+ $identifier,
+ $module->getName(),
+ $providedBy[$identifier]
+ );
+ } elseif (in_array($identifier, static::$defaultBackends)) {
+ Logger::warning(
+ 'Cannot register user group backend of type "%s" provided by module "%s".'
+ . ' The type is a default type provided by Icinga Web 2',
+ $identifier,
+ $module->getName()
+ );
+ } else {
+ $providedBy[$identifier] = $module->getName();
+ static::$customBackends[$identifier] = $className;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get config forms of all custom user group backends
+ */
+ public static function getCustomBackendConfigForms()
+ {
+ $customBackendConfigForms = [];
+ static::registerCustomUserGroupBackends();
+ foreach (self::$customBackends as $customBackendType => $customBackendClass) {
+ if (method_exists($customBackendClass, 'getConfigurationFormClass')) {
+ $customBackendConfigForms[$customBackendType] = $customBackendClass::getConfigurationFormClass();
+ }
+ }
+
+ return $customBackendConfigForms;
+ }
+
+ /**
+ * Return the class for the given custom user group backend
+ *
+ * @param string $identifier The identifier of the custom user group backend
+ *
+ * @return string|null The name of the class or null in case there was no
+ * backend found with the given identifier
+ *
+ * @throws ConfigurationError In case the class associated to the given identifier does not exist
+ */
+ protected static function getCustomUserGroupBackend($identifier)
+ {
+ static::registerCustomUserGroupBackends();
+ if (array_key_exists($identifier, static::$customBackends)) {
+ $className = static::$customBackends[$identifier];
+ if (! class_exists($className)) {
+ throw new ConfigurationError(
+ 'Cannot utilize user group backend of type "%s". Class "%s" does not exist',
+ $identifier,
+ $className
+ );
+ }
+
+ return $className;
+ }
+ }
+
+ /**
+ * Create and return a user group backend with the given name and given configuration applied to it
+ *
+ * @param string $name
+ * @param ConfigObject $backendConfig
+ *
+ * @return UserGroupBackendInterface
+ *
+ * @throws ConfigurationError
+ */
+ public static function create($name, ConfigObject $backendConfig)
+ {
+ if ($backendConfig->name !== null) {
+ $name = $backendConfig->name;
+ }
+
+ if (! ($backendType = strtolower($backendConfig->backend))) {
+ throw new ConfigurationError(
+ 'Configuration for user group backend "%s" is missing the \'backend\' directive',
+ $name
+ );
+ }
+ if (in_array($backendType, static::$defaultBackends)) {
+ // The default backend check is the first one because of performance reasons:
+ // Do not attempt to load a custom user group backend unless it's actually required
+ } elseif (($customClass = static::getCustomUserGroupBackend($backendType)) !== null) {
+ $backend = new $customClass($backendConfig);
+ if (! is_a($backend, 'Icinga\Authentication\UserGroup\UserGroupBackendInterface')) {
+ throw new ConfigurationError(
+ 'Cannot utilize user group backend of type "%s".'
+ . ' Class "%s" does not implement UserGroupBackendInterface',
+ $backendType,
+ $customClass
+ );
+ }
+
+ $backend->setName($name);
+ return $backend;
+ } else {
+ throw new ConfigurationError(
+ 'Configuration for user group backend "%s" defines an invalid backend type.'
+ . ' Backend type "%s" is not supported',
+ $name,
+ $backendType
+ );
+ }
+
+ if ($backendConfig->resource === null) {
+ throw new ConfigurationError(
+ 'Configuration for user group backend "%s" is missing the \'resource\' directive',
+ $name
+ );
+ }
+
+ $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource);
+ if ($backendType === 'db' && $resourceConfig->db === 'mysql') {
+ $resourceConfig->charset = 'utf8mb4';
+ }
+
+ $backend = null;
+ $resource = ResourceFactory::createResource($resourceConfig);
+ switch ($backendType) {
+ case 'db':
+ $backend = new DbUserGroupBackend($resource);
+ break;
+ case 'ldap':
+ case 'msldap':
+ $backend = new LdapUserGroupBackend($resource);
+ $backend->setConfig($backendConfig);
+ break;
+ }
+
+ $backend->setName($name);
+ return $backend;
+ }
+}
diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php
new file mode 100644
index 0000000..cc9438f
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php
@@ -0,0 +1,56 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Icinga\User;
+
+/**
+ * Interface for user group backends
+ */
+interface UserGroupBackendInterface
+{
+ /**
+ * Set this backend's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name);
+
+ /**
+ * Return this backend's name
+ *
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Return the groups the given user is a member of
+ *
+ * @param User $user
+ *
+ * @return array
+ */
+ public function getMemberships(User $user);
+
+ /**
+ * Return the name of the backend that is providing the given user
+ *
+ * @param string $username
+ *
+ * @return null|string The name of the backend or null in case this information is not available
+ */
+ public function getUserBackendName($username);
+
+ /**
+ * Return this backend's configuration form class path
+ *
+ * This is not part of the interface to not break existing implementations.
+ * If you need a custom backend form, implement this method.
+ *
+ * @return string
+ */
+ //public static function getConfigurationFormClass();
+}
diff --git a/library/Icinga/Chart/Axis.php b/library/Icinga/Chart/Axis.php
new file mode 100644
index 0000000..1639939
--- /dev/null
+++ b/library/Icinga/Chart/Axis.php
@@ -0,0 +1,485 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use DOMElement;
+use Icinga\Chart\Primitive\Drawable;
+use Icinga\Chart\Primitive\Line;
+use Icinga\Chart\Primitive\Text;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Render\Rotator;
+use Icinga\Chart\Unit\AxisUnit;
+use Icinga\Chart\Unit\CalendarUnit;
+use Icinga\Chart\Unit\LinearUnit;
+
+/**
+ * Axis class for the GridChart class.
+ *
+ * Implements drawing functions for the axis and its labels but delegates tick and label calculations
+ * to the AxisUnit implementations
+ *
+ * @see GridChart
+ * @see AxisUnit
+ */
+class Axis implements Drawable
+{
+ /**
+ * Draw the label text horizontally
+ */
+ const LABEL_ROTATE_HORIZONTAL = 'normal';
+
+ /**
+ * Draw the label text diagonally
+ */
+ const LABEL_ROTATE_DIAGONAL = 'diagonal';
+
+ /**
+ * Whether to draw the horizontal lines for the background grid
+ *
+ * @var bool
+ */
+ private $drawXGrid = true;
+
+ /**
+ * Whether to draw the vertical lines for the background grid
+ *
+ * @var bool
+ */
+ private $drawYGrid = true;
+
+ /**
+ * The label for the x axis
+ *
+ * @var string
+ */
+ private $xLabel = "";
+
+ /**
+ * The label for the y axis
+ *
+ * @var string
+ */
+ private $yLabel = "";
+
+ /**
+ * The AxisUnit implementation to use for calculating the ticks for the x axis
+ *
+ * @var AxisUnit
+ */
+ private $xUnit = null;
+
+ /**
+ * The AxisUnit implementation to use for calculating the ticks for the y axis
+ *
+ * @var AxisUnit
+ */
+ private $yUnit = null;
+
+ /**
+ * The minimum amount of units each step must take up
+ *
+ * @var int
+ */
+ public $minUnitsPerStep = 80;
+
+ /**
+ * The minimum amount of units each tick must take up
+ *
+ * @var int
+ */
+ public $minUnitsPerTick = 15;
+
+ /**
+ * If the displayed labels should be aligned horizontally or diagonally
+ */
+ protected $labelRotationStyle = self::LABEL_ROTATE_HORIZONTAL;
+
+ /**
+ * Inform the axis about an added dataset
+ *
+ * This is especially needed when one or more AxisUnit implementations dynamically define
+ * their min or max values, as this is the point where they detect the min and max value
+ * from the datasets
+ *
+ * @param array $dataset An dataset to respect on axis generation
+ */
+ public function addDataset(array $dataset)
+ {
+ $this->xUnit->addValues($dataset, 0);
+ $this->yUnit->addValues($dataset, 1);
+ }
+
+ /**
+ * Set the AxisUnit implementation to use for generating the x axis
+ *
+ * @param AxisUnit $unit The AxisUnit implementation to use for the x axis
+ *
+ * @return $this This Axis Object
+ * @see Axis::CalendarUnit
+ * @see Axis::LinearUnit
+ */
+ public function setUnitForXAxis(AxisUnit $unit)
+ {
+ $this->xUnit = $unit;
+ return $this;
+ }
+
+ /**
+ * Set the AxisUnit implementation to use for generating the y axis
+ *
+ * @param AxisUnit $unit The AxisUnit implementation to use for the y axis
+ *
+ * @return $this This Axis Object
+ * @see Axis::CalendarUnit
+ * @see Axis::LinearUnit
+ */
+ public function setUnitForYAxis(AxisUnit $unit)
+ {
+ $this->yUnit = $unit;
+ return $this;
+ }
+
+ /**
+ * Return the padding this axis requires
+ *
+ * @return array An array containing the padding for all sides
+ */
+ public function getRequiredPadding()
+ {
+ return array(10, 5, 15, 10);
+ }
+
+ /**
+ * Render the horizontal axis
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ * @param DOMElement $group The DOMElement this axis will be added to
+ */
+ private function renderHorizontalAxis(RenderContext $ctx, DOMElement $group)
+ {
+ $steps = $this->ticksPerX($this->xUnit->getTicks(), $ctx->getNrOfUnitsX(), $this->minUnitsPerStep);
+ $ticks = $this->ticksPerX($this->xUnit->getTicks(), $ctx->getNrOfUnitsX(), $this->minUnitsPerTick);
+
+ // Steps should always be ticks
+ if ($ticks !== $steps) {
+ $steps = $ticks * 5;
+ }
+
+ // Check whether there is enough room for regular labels
+ $labelRotationStyle = $this->labelRotationStyle;
+ if ($this->labelsOversized($this->xUnit, 6)) {
+ $labelRotationStyle = self::LABEL_ROTATE_DIAGONAL;
+ }
+
+ /*
+ $line = new Line(0, 100, 100, 100);
+ $line->setStrokeWidth(2);
+ $group->appendChild($line->toSvg($ctx));
+ */
+
+ // contains the approximate end position of the last label
+ $lastLabelEnd = -1;
+ $shift = 0;
+
+ $i = 0;
+ foreach ($this->xUnit as $label => $pos) {
+ if ($i % $ticks === 0) {
+ /*
+ $tick = new Line($pos, 100, $pos, 101);
+ $group->appendChild($tick->toSvg($ctx));
+ */
+ }
+
+ if ($i % $steps === 0) {
+ if ($labelRotationStyle === self::LABEL_ROTATE_HORIZONTAL) {
+ // If the last label would overlap this label we shift the y axis a bit
+ if ($lastLabelEnd > $pos) {
+ $shift = ($shift + 5) % 10;
+ } else {
+ $shift = 0;
+ }
+ }
+
+ $labelField = new Text($pos + 0.5, ($this->xLabel ? 107 : 105) + $shift, $label);
+ if ($labelRotationStyle === self::LABEL_ROTATE_HORIZONTAL) {
+ $labelField->setAlignment(Text::ALIGN_MIDDLE)
+ ->setFontSize('2.5em');
+ } else {
+ $labelField->setFontSize('2.5em');
+ }
+
+ if ($labelRotationStyle === self::LABEL_ROTATE_DIAGONAL) {
+ $labelField = new Rotator($labelField, 45);
+ }
+ $labelField = $labelField->toSvg($ctx);
+
+ $group->appendChild($labelField);
+
+ if ($this->drawYGrid) {
+ $bgLine = new Line($pos, 0, $pos, 100);
+ $bgLine->setStrokeWidth(0.5)
+ ->setStrokeColor('#BFBFBF');
+ $group->appendChild($bgLine->toSvg($ctx));
+ }
+ $lastLabelEnd = $pos + strlen($label) * 1.2;
+ }
+ $i++;
+ }
+ }
+
+ /**
+ * Render the vertical axis
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ * @param DOMElement $group The DOMElement this axis will be added to
+ */
+ private function renderVerticalAxis(RenderContext $ctx, DOMElement $group)
+ {
+ $steps = $this->ticksPerX($this->yUnit->getTicks(), $ctx->getNrOfUnitsY(), $this->minUnitsPerStep);
+ $ticks = $this->ticksPerX($this->yUnit->getTicks(), $ctx->getNrOfUnitsY(), $this->minUnitsPerTick);
+
+ // Steps should always be ticks
+ if ($ticks !== $steps) {
+ $steps = $ticks * 5;
+ }
+ /*
+ $line = new Line(0, 0, 0, 100);
+ $line->setStrokeWidth(2);
+ $group->appendChild($line->toSvg($ctx));
+ */
+
+ $i = 0;
+ foreach ($this->yUnit as $label => $pos) {
+ $pos = 100 - $pos;
+
+ if ($i % $ticks === 0) {
+ // draw a tick
+ //$tick = new Line(0, $pos, -1, $pos);
+ //$group->appendChild($tick->toSvg($ctx));
+ }
+
+ if ($i % $steps === 0) {
+ // draw a step
+ $labelField = new Text(-0.5, $pos + 0.5, $label);
+ $labelField->setFontSize('2.5em')
+ ->setAlignment(Text::ALIGN_END);
+
+ $group->appendChild($labelField->toSvg($ctx));
+ if ($this->drawXGrid) {
+ $bgLine = new Line(0, $pos, 100, $pos);
+ $bgLine->setStrokeWidth(0.5)
+ ->setStrokeColor('#BFBFBF');
+ $group->appendChild($bgLine->toSvg($ctx));
+ }
+ }
+ $i++;
+ }
+
+ if ($this->yLabel || $this->xLabel) {
+ if ($this->yLabel && $this->xLabel) {
+ $txt = $this->yLabel . ' / ' . $this->xLabel;
+ } elseif ($this->xLabel) {
+ $txt = $this->xLabel;
+ } else {
+ $txt = $this->yLabel;
+ }
+
+ $axisLabel = new Text(50, -3, $txt);
+ $axisLabel->setFontSize('2em')
+ ->setFontWeight('bold')
+ ->setAlignment(Text::ALIGN_MIDDLE);
+
+ $group->appendChild($axisLabel->toSvg($ctx));
+ }
+ }
+
+ /**
+ * Factory method, create an Axis instance using Linear ticks as the unit
+ *
+ * @return Axis The axis that has been created
+ * @see LinearUnit
+ */
+ public static function createLinearAxis()
+ {
+ $axis = new Axis();
+ $axis->setUnitForXAxis(self::linearUnit());
+ $axis->setUnitForYAxis(self::linearUnit());
+ return $axis;
+ }
+
+ /**
+ * Set the label for the x axis
+ *
+ * An empty string means 'no label'.
+ *
+ * @param string $label The label to use for the x axis
+ *
+ * @return $this Fluid interface
+ */
+ public function setXLabel($label)
+ {
+ $this->xLabel = $label;
+ return $this;
+ }
+
+ /**
+ * Set the label for the y axis
+ *
+ * An empty string means 'no label'.
+ *
+ * @param string $label The label to use for the y axis
+ *
+ * @return $this Fluid interface
+ */
+ public function setYLabel($label)
+ {
+ $this->yLabel = $label;
+ return $this;
+ }
+
+ /**
+ * Set the labels minimum value for the x axis
+ *
+ * Setting the value to null let's the axis unit decide which value to use for the minimum
+ *
+ * @param int $xMin The minimum value to use for the x axis
+ *
+ * @return $this Fluid interface
+ */
+ public function setXMin($xMin)
+ {
+ $this->xUnit->setMin($xMin);
+ return $this;
+ }
+
+ /**
+ * Set the labels minimum value for the y axis
+ *
+ * Setting the value to null let's the axis unit decide which value to use for the minimum
+ *
+ * @param int $yMin The minimum value to use for the x axis
+ *
+ * @return $this Fluid interface
+ */
+ public function setYMin($yMin)
+ {
+ $this->yUnit->setMin($yMin);
+ return $this;
+ }
+
+ /**
+ * Set the labels maximum value for the x axis
+ *
+ * Setting the value to null let's the axis unit decide which value to use for the maximum
+ *
+ * @param int $xMax The minimum value to use for the x axis
+ *
+ * @return $this Fluid interface
+ */
+ public function setXMax($xMax)
+ {
+ $this->xUnit->setMax($xMax);
+ return $this;
+ }
+
+ /**
+ * Set the labels maximum value for the y axis
+ *
+ * Setting the value to null let's the axis unit decide which value to use for the maximum
+ *
+ * @param int $yMax The minimum value to use for the y axis
+ *
+ * @return $this Fluid interface
+ */
+ public function setYMax($yMax)
+ {
+ $this->yUnit->setMax($yMax);
+ return $this;
+ }
+
+ /**
+ * Transform all coordinates of the given dataset to coordinates that fit the graph's coordinate system
+ *
+ * @param array $dataSet The absolute coordinates as provided in the draw call
+ *
+ * @return array A graph relative representation of the given coordinates
+ */
+ public function transform(array &$dataSet)
+ {
+ $result = array();
+ foreach ($dataSet as &$points) {
+ $result[] = array(
+ $this->xUnit->transform($points[0]),
+ 100 - $this->yUnit->transform($points[1])
+ );
+ }
+ return $result;
+ }
+
+ /**
+ * Create an AxisUnit that can be used in the axis to represent timestamps
+ *
+ * @return CalendarUnit
+ */
+ public static function calendarUnit()
+ {
+ return new CalendarUnit();
+ }
+
+ /**
+ * Create an AxisUnit that can be used in the axis to represent a dataset as equally distributed
+ * ticks
+ *
+ * @param int $ticks
+ * @return LinearUnit
+ */
+ public static function linearUnit($ticks = 10)
+ {
+ return new LinearUnit($ticks);
+ }
+
+ /**
+ * Return the SVG representation of this object
+ *
+ * @param RenderContext $ctx The context to use for calculations
+ *
+ * @return DOMElement
+ * @see Drawable::toSvg
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $group = $ctx->getDocument()->createElement('g');
+ $this->renderHorizontalAxis($ctx, $group);
+ $this->renderVerticalAxis($ctx, $group);
+ return $group;
+ }
+
+ protected function ticksPerX($ticks, $units, $min)
+ {
+ $per = 1;
+ while ($per * $units / $ticks < $min) {
+ $per++;
+ }
+ return $per;
+ }
+
+ /**
+ * Returns whether at least one label of the given Axis
+ * is bigger than the given maxLength
+ *
+ * @param AxisUnit $axis The axis that contains the labels that will be checked
+ *
+ * @return boolean Whether at least one label is bigger than maxLength
+ */
+ private function labelsOversized(AxisUnit $axis, $maxLength = 5)
+ {
+ $oversized = false;
+ foreach ($axis as $label => $pos) {
+ if (strlen($label) > $maxLength) {
+ $oversized = true;
+ }
+ }
+ return $oversized;
+ }
+}
diff --git a/library/Icinga/Chart/Chart.php b/library/Icinga/Chart/Chart.php
new file mode 100644
index 0000000..eaf69d1
--- /dev/null
+++ b/library/Icinga/Chart/Chart.php
@@ -0,0 +1,162 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use Imagick;
+use Icinga\Chart\Legend;
+use Icinga\Chart\Palette;
+use Icinga\Chart\Primitive\Drawable;
+use Icinga\Chart\SVGRenderer;
+use Icinga\Exception\IcingaException;
+
+/**
+ * Base class for charts, extended by all other Chart classes.
+ */
+abstract class Chart implements Drawable
+{
+ protected $align = false;
+
+ /**
+ * SVG renderer that handles
+ *
+ * @var SVGRenderer
+ */
+ protected $renderer;
+
+ /**
+ * Legend to use for this chart
+ *
+ * @var Legend
+ */
+ protected $legend;
+
+ /**
+ * The style-palette for this chart
+ *
+ * @var Palette
+ */
+ protected $palette;
+
+ /**
+ * The title of this chart, used for providing accessibility features
+ *
+ * @var string
+ */
+ public $title;
+
+ /**
+ * The description for this chart, mandatory for providing accessibility features
+ *
+ * @var string
+ */
+ public $description;
+
+ /**
+ * Create a new chart object and create internal objects
+ *
+ * If you want to extend this class use the init() method as an extension point,
+ * as this will be called at the end of the construct call
+ */
+ public function __construct()
+ {
+ $this->legend = new Legend();
+ $this->palette = new Palette();
+ $this->init();
+ }
+
+ /**
+ * Extension point for subclasses, called on __construct
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Extension point for implementing rendering logic
+ *
+ * This method is called after data validation, but before toSvg is called
+ */
+ protected function build()
+ {
+ }
+
+ /**
+ * Check if the current dataset has the proper structure for this chart.
+ *
+ * Needs to be overwritten by extending classes. The default implementation returns false.
+ *
+ * @return bool True when the dataset is valid, otherwise false
+ */
+ abstract public function isValidDataFormat();
+
+
+ /**
+ * Disable the legend for this chart
+ */
+ public function disableLegend()
+ {
+ $this->legend = null;
+ }
+
+ /**
+ * Render this graph and return the created SVG
+ *
+ * @return string The SVG created by the SvgRenderer
+ *
+ * @throws IcingaException Thrown wen the dataset is not valid for this graph
+ * @see SVGRenderer::render
+ */
+ public function render()
+ {
+ if (!$this->isValidDataFormat()) {
+ throw new IcingaException('Dataset for graph doesn\'t have the proper structure');
+ }
+ $this->build();
+ if ($this->align) {
+ $this->renderer->preserveAspectRatio();
+ $this->renderer->setXAspectRatioAlignment(SVGRenderer::X_ASPECT_RATIO_MIN);
+ $this->renderer->setYAspectRatioAlignment(SVGRenderer::Y_ASPECT_RATIO_MIN);
+ }
+
+ $this->renderer->setAriaDescription($this->description);
+ $this->renderer->setAriaTitle($this->title);
+ $this->renderer->getCanvas()->setAriaRole('presentation');
+
+ $this->renderer->getCanvas()->addElement($this);
+ return $this->renderer->render();
+ }
+
+ /**
+ * Return this graph rendered as PNG
+ *
+ * @param int $width The width of the PNG in pixel
+ * @param int $height The height of the PNG in pixel
+ *
+ * @return string A PNG binary string
+ *
+ * @throws IcingaException In case ImageMagick is not available
+ */
+ public function toPng($width, $height)
+ {
+ if (! class_exists('Imagick')) {
+ throw new IcingaException('Cannot render PNGs without ImageMagick');
+ }
+
+ $image = new Imagick();
+ $image->readImageBlob($this->render());
+ $image->setImageFormat('png24');
+ $image->resizeImage($width, $height, imagick::FILTER_LANCZOS, 1);
+ return $image;
+ }
+
+ /**
+ * Align the chart to the top left corner instead of centering it
+ *
+ * @param bool $align
+ */
+ public function alignTopLeft($align = true)
+ {
+ $this->align = $align;
+ }
+}
diff --git a/library/Icinga/Chart/Donut.php b/library/Icinga/Chart/Donut.php
new file mode 100644
index 0000000..9d2a2a8
--- /dev/null
+++ b/library/Icinga/Chart/Donut.php
@@ -0,0 +1,465 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use Icinga\Web\Url;
+
+/** Donut chart implementation */
+class Donut
+{
+ /**
+ * Big label in the middle of the donut, color is critical (red)
+ *
+ * @var string
+ */
+ protected $labelBig;
+
+ /**
+ * Url behind the big label
+ *
+ * @var Url
+ */
+ protected $labelBigUrl;
+
+ /**
+ * The state the big label shall indicate
+ *
+ * @var string|null
+ */
+ protected $labelBigState = 'critical';
+
+ /**
+ * Small label in the lower part of the donuts hole
+ *
+ * @var string
+ */
+ protected $labelSmall;
+
+ /**
+ * Thickness of the donut ring
+ *
+ * @var int
+ */
+ protected $thickness = 6;
+
+ /**
+ * Radius based of 100 to simplify the calculations
+ *
+ * 100 / (2 * M_PI)
+ *
+ * @var float
+ */
+ protected $radius = 15.9154943092;
+
+ /**
+ * Color of the hole in the donut
+ *
+ * Transparent by default so it can be placed anywhere with ease
+ *
+ * @var string
+ */
+ protected $centerColor = 'transparent';
+
+ /**
+ * The different colored parts that represent the data
+ *
+ * @var array
+ */
+ protected $slices = array();
+
+ /**
+ * The total amount of data units
+ *
+ * @var int
+ */
+ protected $count = 0;
+
+ /**
+ * Adds a colored part that represent the data
+ *
+ * @param integer $data Units of data
+ * @param array $attributes HTML attributes for this slice. (For example ['class' => 'slice-state-ok'])
+ *
+ * @return $this
+ */
+ public function addSlice($data, $attributes = array())
+ {
+ $this->slices[] = array($data, $attributes);
+
+ $this->count += $data;
+
+ return $this;
+ }
+
+ /**
+ * Set the thickness for this Donut
+ *
+ * @param integer $thickness
+ *
+ * @return $this
+ */
+ public function setThickness($thickness)
+ {
+ $this->thickness = $thickness;
+
+ return $this;
+ }
+ /**
+ * Get the thickness for this Donut
+ *
+ * @return integer
+ */
+ public function getThickness()
+ {
+ return $this->thickness;
+ }
+
+ /**
+ * Set the center color for this Donut
+ *
+ * @param string $centerColor
+ *
+ * @return $this
+ */
+ public function setCenterColor($centerColor)
+ {
+ $this->centerColor = $centerColor;
+
+ return $this;
+ }
+
+ /**
+ * Get the center color for this Donut
+ *
+ * @return string
+ */
+ public function getCenterColor()
+ {
+ return $this->centerColor;
+ }
+
+ /**
+ * Set the text of the big label
+ *
+ * @param string $labelBig
+ *
+ * @return $this
+ */
+ public function setLabelBig($labelBig)
+ {
+ $this->labelBig = $labelBig;
+
+ return $this;
+ }
+
+ /**
+ * Get the text of the big label
+ *
+ * @return string
+ */
+ public function getLabelBig()
+ {
+ return $this->labelBig;
+ }
+
+ /**
+ * Set the url behind the big label
+ *
+ * @param Url $labelBigUrl
+ *
+ * @return $this
+ */
+ public function setLabelBigUrl($labelBigUrl)
+ {
+ $this->labelBigUrl = $labelBigUrl;
+
+ return $this;
+ }
+
+ /**
+ * Get the url behind the big label
+ *
+ * @return Url
+ */
+ public function getLabelBigUrl()
+ {
+ return $this->labelBigUrl;
+ }
+
+ /**
+ * Get whether the big label shall be eye-catching
+ *
+ * @return bool
+ */
+ public function getLabelBigEyeCatching()
+ {
+ return $this->labelBigState !== null;
+ }
+
+ /**
+ * Set whether the big label shall be eye-catching
+ *
+ * @param bool $labelBigEyeCatching
+ *
+ * @return $this
+ */
+ public function setLabelBigEyeCatching($labelBigEyeCatching = true)
+ {
+ $this->labelBigState = $labelBigEyeCatching ? 'critical' : null;
+
+ return $this;
+ }
+
+ /**
+ * Get the state the big label shall indicate
+ *
+ * @return string|null
+ */
+ public function getLabelBigState()
+ {
+ return $this->labelBigState;
+ }
+
+ /**
+ * Set the state the big label shall indicate
+ *
+ * @param string|null $labelBigState
+ *
+ * @return $this
+ */
+ public function setLabelBigState($labelBigState)
+ {
+ $this->labelBigState = $labelBigState;
+
+ return $this;
+ }
+
+ /**
+ * Set the text of the small label
+ *
+ * @param string $labelSmall
+ *
+ * @return $this
+ */
+ public function setLabelSmall($labelSmall)
+ {
+ $this->labelSmall = $labelSmall;
+
+ return $this;
+ }
+
+ /**
+ * Get the text of the small label
+ *
+ * @return string
+ */
+ public function getLabelSmall()
+ {
+ return $this->labelSmall;
+ }
+
+ /**
+ * Put together all slices of this Donut
+ *
+ * @return array $svg
+ */
+ protected function assemble()
+ {
+ // svg tag containing the ring
+ $svg = array(
+ 'tag' => 'svg',
+ 'attributes' => array(
+ 'xmlns' => 'http://www.w3.org/2000/svg',
+ 'viewbox' => '0 0 40 40',
+ 'class' => 'donut-graph'
+ ),
+ 'content' => array()
+ );
+
+ // Donut hole
+ $svg['content'][] = array(
+ 'tag' => 'circle',
+ 'attributes' => array(
+ 'cx' => 20,
+ 'cy' => 20,
+ 'r' => sprintf('%F', $this->radius),
+ 'fill' => $this->getCenterColor()
+ )
+ );
+
+ // When there is no data show gray circle
+ $svg['content'][] = array(
+ 'tag' => 'circle',
+ 'attributes' => array(
+ 'aria-hidden' => true,
+ 'cx' => 20,
+ 'cy' => 20,
+ 'r' => sprintf('%F', $this->radius),
+ 'fill' => $this->getCenterColor(),
+ 'stroke-width' => $this->getThickness(),
+ 'class' => 'slice-state-not-checked'
+ )
+ );
+
+ $slices = $this->slices;
+
+ if ($this->count !== 0) {
+ array_walk($slices, function (&$slice) {
+ $slice[0] = round(100 / $this->count * $slice[0], 2);
+ });
+ }
+
+ // on 0 the donut would start at "3 o'clock" and the offset shifts counterclockwise
+ $offset = 25;
+
+ foreach ($slices as $slice) {
+ $svg['content'][] = array(
+ 'tag' => 'circle',
+ 'attributes' => $slice[1] + array(
+ 'cx' => 20,
+ 'cy' => 20,
+ 'r' => sprintf('%F', $this->radius),
+ 'fill' => 'transparent',
+ 'stroke-width' => $this->getThickness(),
+ 'stroke-dasharray' => sprintf('%F', $slice[0])
+ . ' '
+ . sprintf('%F', (99.9 - $slice[0])), // 99.9 prevents gaps (slight overlap)
+ 'stroke-dashoffset' => sprintf('%F', $offset)
+ )
+ );
+ // negative values shift in the clockwise direction
+ $offset -= $slice[0];
+ }
+
+ $result = array(
+ 'tag' => 'div',
+ 'content' => array($svg)
+ );
+
+ $labelBig = (string) $this->getLabelBig();
+ $labelSmall = (string) $this->getLabelSmall();
+
+ if ($labelBig !== '' || $labelSmall !== '') {
+ $labels = array(
+ 'tag' => 'div',
+ 'attributes' => array(
+ 'class' => 'donut-label'
+ ),
+ 'content' => array()
+ );
+
+ if ($labelBig !== '') {
+ $labels['content'][] =
+ array(
+ 'tag' => 'a',
+ 'attributes' => array(
+ 'aria-label' => $labelBig . ' ' . $labelSmall,
+ 'href' => $this->getLabelBigUrl() ? $this->getLabelBigUrl()->getAbsoluteUrl() : null,
+ 'class' => $this->labelBigState === null
+ ? 'donut-label-big'
+ : 'donut-label-big state-' . $this->labelBigState
+ ),
+ 'content' => $this->shortenLabel($labelBig)
+ );
+ }
+
+ if ($labelSmall !== '') {
+ $labels['content'][] = array(
+ 'tag' => 'p',
+ 'attributes' => array(
+ 'class' => 'donut-label-small',
+ 'x' => '50%',
+ 'y' => '50%'
+ ),
+ 'content' => $labelSmall
+ );
+ }
+
+ $result['content'][] = $labels;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Shorten the label to 3 digits if it is numeric
+ *
+ * 10 => 10 ... 1111 => ~1k ... 1888 => ~2k
+ *
+ * @param int|string $label
+ *
+ * @return string
+ */
+ protected function shortenLabel($label)
+ {
+ if (is_numeric($label) && strlen($label) > 3) {
+ return round($label, -3)/1000 . 'k';
+ }
+
+ return $label;
+ }
+
+ protected function encode($content)
+ {
+ return htmlspecialchars($content, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true);
+ }
+
+ protected function renderAttributes(array $attributes)
+ {
+ $html = array();
+
+ foreach ($attributes as $name => $value) {
+ if ($value === null) {
+ continue;
+ }
+
+ if (is_bool($value) && $value) {
+ $html[] = $name;
+ continue;
+ }
+
+ if (is_array($value)) {
+ $value = implode(' ', $value);
+ }
+
+ $html[] = "$name=\"" . $this->encode($value) . '"';
+ }
+
+ return implode(' ', $html);
+ }
+
+ protected function renderContent(array $element)
+ {
+ $tag = $element['tag'];
+ $attributes = isset($element['attributes']) ? $element['attributes'] : array();
+ $content = isset($element['content']) ? $element['content'] : null;
+
+ $html = array(
+ // rtrim because attributes may be empty
+ rtrim("<$tag " . $this->renderAttributes($attributes))
+ . ">"
+ );
+
+ if ($content !== null) {
+ if (is_array($content)) {
+ foreach ($content as $child) {
+ $html[] = is_array($child) ? $this->renderContent($child) : $this->encode($child);
+ }
+ } else {
+ $html[] = $this->encode($content);
+ }
+ }
+
+ $html[] = "</$tag>";
+
+ return implode("\n", $html);
+ }
+
+ public function render()
+ {
+ $svg = $this->assemble();
+
+ return $this->renderContent($svg);
+ }
+}
diff --git a/library/Icinga/Chart/Format.php b/library/Icinga/Chart/Format.php
new file mode 100644
index 0000000..9e6c4db
--- /dev/null
+++ b/library/Icinga/Chart/Format.php
@@ -0,0 +1,21 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+class Format
+{
+ /**
+ * Format a number into a number-string as defined by the SVG-Standard
+ *
+ * @see http://www.w3.org/TR/SVG/types.html#DataTypeNumber
+ *
+ * @param $number
+ *
+ * @return string
+ */
+ public static function formatSVGNumber($number)
+ {
+ return number_format($number, 1, '.', '');
+ }
+}
diff --git a/library/Icinga/Chart/Graph/BarGraph.php b/library/Icinga/Chart/Graph/BarGraph.php
new file mode 100644
index 0000000..be142bf
--- /dev/null
+++ b/library/Icinga/Chart/Graph/BarGraph.php
@@ -0,0 +1,163 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Graph;
+
+use DOMElement;
+use Icinga\Chart\Primitive\Animation;
+use Icinga\Chart\Primitive\Drawable;
+use Icinga\Chart\Primitive\Rect;
+use Icinga\Chart\Primitive\Styleable;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Bar graph implementation
+ */
+class BarGraph extends Styleable implements Drawable
+{
+ /**
+ * The dataset order
+ *
+ * @var int
+ */
+ private $order = 0;
+
+ /**
+ * The width of the bars.
+ *
+ * @var int
+ */
+ private $barWidth = 3;
+
+ /**
+ * The dataset to use for this bar graph
+ *
+ * @var array
+ */
+ private $dataSet;
+
+ /**
+ * The tooltips
+ *
+ * @var
+ */
+ private $tooltips;
+
+ /**
+ * All graphs
+ *
+ * @var
+ */
+ private $graphs;
+
+ /**
+ * Create a new BarGraph with the given dataset
+ *
+ * @param array $dataSet An array of data points
+ * @param int $order The graph number displayed by this BarGraph
+ * @param array $tooltips The tooltips to display for each value
+ */
+ public function __construct(
+ array $dataSet,
+ array &$graphs,
+ $order,
+ array $tooltips = null
+ ) {
+ $this->order = $order;
+ $this->dataSet = $dataSet;
+
+ $this->tooltips = $tooltips;
+ $ts = [];
+ foreach ($this->tooltips as $value) {
+ $ts[] = $value;
+ }
+ $this->tooltips = $ts;
+
+ $this->graphs = $graphs;
+ }
+
+ /**
+ * Apply configuration styles from the $cfg
+ *
+ * @param array $cfg The configuration as given in the drawBars call
+ */
+ public function setStyleFromConfig(array $cfg)
+ {
+ foreach ($cfg as $elem => $value) {
+ if ($elem === 'color') {
+ $this->setFill($value);
+ } elseif ($elem === 'width') {
+ $this->setStrokeWidth($value);
+ }
+ }
+ }
+
+ /**
+ * Draw a single rectangle
+ *
+ * @param array $point The
+ * @param string $fill The fill color to use
+ * @param $strokeWidth
+ * @param ?int $index
+ *
+ * @return Rect
+ */
+ private function drawSingleBar($point, $fill, $strokeWidth, $index = null)
+ {
+ $rect = new Rect($point[0] - ($this->barWidth / 2), $point[1], $this->barWidth, 100 - $point[1]);
+ $rect->setFill($fill);
+ $rect->setStrokeWidth($strokeWidth);
+ $rect->setStrokeColor('black');
+ if (isset($index)) {
+ $rect->setAttribute('data-icinga-graph-index', $index);
+ }
+ $rect->setAttribute('data-icinga-graph-type', 'bar');
+ $rect->setAdditionalStyle(['clip-path' => 'url(#clip)']);
+ return $rect;
+ }
+
+ /**
+ * Render this BarChart
+ *
+ * @param RenderContext $ctx The rendering context to use for drawing
+ *
+ * @return DOMElement $dom Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $doc = $ctx->getDocument();
+ $group = $doc->createElement('g');
+ $idx = 0;
+
+ if (count($this->dataSet) > 15) {
+ $this->barWidth = 2;
+ }
+ if (count($this->dataSet) > 25) {
+ $this->barWidth = 1;
+ }
+
+ foreach ($this->dataSet as $x => $point) {
+ // add white background bar, to prevent other bars from altering transparency effects
+ $bar = $this->drawSingleBar($point, 'white', $this->strokeWidth, $idx++)->toSvg($ctx);
+ $group->appendChild($bar);
+
+ // draw actual bar
+ $bar = $this->drawSingleBar($point, $this->fill, $this->strokeWidth)->toSvg($ctx);
+ if (isset($this->tooltips[$x])) {
+ $data = array(
+ 'label' => isset($this->graphs[$this->order]['label']) ?
+ strtolower($this->graphs[$this->order]['label']) : '',
+ 'color' => isset($this->graphs[$this->order]['color']) ?
+ strtolower($this->graphs[$this->order]['color']) : '#fff'
+ );
+ $format = isset($this->graphs[$this->order]['tooltip'])
+ ? $this->graphs[$this->order]['tooltip'] : null;
+ $title = $ctx->getDocument()->createElement('title');
+ $title->textContent = $this->tooltips[$x]->renderNoHtml($this->order, $data, $format);
+ $bar->appendChild($title);
+ }
+ $group->appendChild($bar);
+ }
+ return $group;
+ }
+}
diff --git a/library/Icinga/Chart/Graph/LineGraph.php b/library/Icinga/Chart/Graph/LineGraph.php
new file mode 100644
index 0000000..21f930a
--- /dev/null
+++ b/library/Icinga/Chart/Graph/LineGraph.php
@@ -0,0 +1,202 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Graph;
+
+use DOMElement;
+use Icinga\Chart\Primitive\Drawable;
+use Icinga\Chart\Primitive\Path;
+use Icinga\Chart\Primitive\Circle;
+use Icinga\Chart\Primitive\Styleable;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * LineGraph implementation for drawing a set of datapoints as
+ * a connected path
+ */
+class LineGraph extends Styleable implements Drawable
+{
+ /**
+ * The dataset to use
+ *
+ * @var array
+ */
+ private $dataset;
+
+ /**
+ * True to show dots for each datapoint
+ *
+ * @var bool
+ */
+ private $showDataPoints = false;
+
+ /**
+ * When true, the path will be discrete, i.e. showing hard steps instead of a direct line
+ *
+ * @var bool
+ */
+ private $isDiscrete = false;
+
+ /**
+ * The tooltips
+ *
+ * @var
+ */
+ private $tooltips;
+
+ /** @var array */
+ private $graphs;
+
+ /** @var int */
+ private $order;
+
+ /**
+ * The default stroke width
+ * @var int
+ */
+ public $strokeWidth = 5;
+
+ /**
+ * The size of the displayed dots
+ *
+ * @var int
+ */
+ public $dotWith = 0;
+
+ /**
+ * Create a new LineGraph displaying the given dataset
+ *
+ * @param array $dataset An array of [x, y] arrays to display
+ */
+ public function __construct(
+ array $dataset,
+ array &$graphs,
+ $order,
+ array $tooltips = null
+ ) {
+ usort($dataset, array($this, 'sortByX'));
+ $this->dataset = $dataset;
+ $this->graphs = $graphs;
+
+ $this->tooltips = $tooltips;
+ $ts = [];
+ foreach ($this->tooltips as $value) {
+ $ts[] = $value;
+ }
+ $this->tooltips = $ts;
+ $this->order = $order;
+ }
+
+ /**
+ * Set datapoints to be emphased via dots
+ *
+ * @param bool $bool True to enable datapoints, otherwise false
+ */
+ public function setShowDataPoints($bool)
+ {
+ $this->showDataPoints = $bool;
+ }
+
+ /**
+ * Sort the daset by the xaxis
+ *
+ * @param array $v1
+ * @param array $v2
+ * @return int
+ */
+ private function sortByX(array $v1, array $v2)
+ {
+ if ($v1[0] === $v2[0]) {
+ return 0;
+ }
+ return ($v1[0] < $v2[0]) ? -1 : 1;
+ }
+
+ /**
+ * Configure this style
+ *
+ * @param array $cfg The configuration as given in the drawLine call
+ */
+ public function setStyleFromConfig(array $cfg)
+ {
+ $fill = false;
+ foreach ($cfg as $elem => $value) {
+ if ($elem === 'color') {
+ $this->setStrokeColor($value);
+ } elseif ($elem === 'width') {
+ $this->setStrokeWidth($value);
+ } elseif ($elem === 'showPoints') {
+ $this->setShowDataPoints($value);
+ } elseif ($elem === 'fill') {
+ $fill = $value;
+ } elseif ($elem === 'discrete') {
+ $this->isDiscrete = true;
+ }
+ }
+ if ($fill) {
+ $this->setFill($this->strokeColor);
+ $this->setStrokeColor('black');
+ }
+ }
+
+ /**
+ * Render this BarChart
+ *
+ * @param RenderContext $ctx The rendering context to use for drawing
+ *
+ * @return DOMElement $dom Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $path = new Path($this->dataset);
+ if ($this->isDiscrete) {
+ $path->setDiscrete(true);
+ }
+ $path->setStrokeColor($this->strokeColor);
+ $path->setStrokeWidth($this->strokeWidth);
+
+ $path->setAttribute('data-icinga-graph-type', 'line');
+ if ($this->fill !== 'none') {
+ $firstX = $this->dataset[0][0];
+ $lastX = $this->dataset[count($this->dataset)-1][0];
+ $path->prepend(array($firstX, 100))
+ ->append(array($lastX, 100));
+ $path->setFill($this->fill);
+ }
+
+ $path->setAdditionalStyle(['clip-path' => 'url(#clip)']);
+ $path->setId($this->id ?? uniqid('line-graph-'));
+ $group = $path->toSvg($ctx);
+
+ foreach ($this->dataset as $x => $point) {
+ if ($this->showDataPoints === true) {
+ $dot = new Circle($point[0], $point[1], $this->dotWith);
+ $dot->setFill($this->strokeColor);
+ $group->appendChild($dot->toSvg($ctx));
+ }
+
+ // Draw invisible circle for tooltip hovering
+ if (isset($this->tooltips[$x])) {
+ $invisible = new Circle($point[0], $point[1], 20);
+ $invisible->setFill($this->strokeColor);
+ $invisible->setAdditionalStyle(['opacity' => '0.0']);
+ $data = array(
+ 'label' => isset($this->graphs[$this->order]['label']) ?
+ strtolower($this->graphs[$this->order]['label']) : '',
+ 'color' => isset($this->graphs[$this->order]['color']) ?
+ strtolower($this->graphs[$this->order]['color']) : '#fff'
+ );
+ $format = isset($this->graphs[$this->order]['tooltip'])
+ ? $this->graphs[$this->order]['tooltip'] : null;
+ $title = $ctx->getDocument()->createElement('title');
+ $title->textContent = $this->tooltips[$x]->renderNoHtml($this->order, $data, $format);
+ $invisibleRendered = $invisible->toSvg($ctx);
+ $invisibleRendered->appendChild($title);
+ $group->appendChild($invisibleRendered);
+ }
+ }
+
+ return $group;
+ }
+}
diff --git a/library/Icinga/Chart/Graph/StackedGraph.php b/library/Icinga/Chart/Graph/StackedGraph.php
new file mode 100644
index 0000000..49801a9
--- /dev/null
+++ b/library/Icinga/Chart/Graph/StackedGraph.php
@@ -0,0 +1,88 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Graph;
+
+use DOMElement;
+use Icinga\Chart\Primitive\Drawable;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Graph implementation that stacks several graphs and displays them in a cumulative way
+ */
+class StackedGraph implements Drawable
+{
+ /**
+ * All graphs displayed in this stackedgraph
+ *
+ * @var array
+ */
+ private $stack = array();
+
+ /**
+ * An associative array containing x points as the key and an array of y values as the value
+ *
+ * @var array
+ */
+ private $points = array();
+
+ /**
+ * Add a graph to this stack and aggregate the values on the fly
+ *
+ * This modifies the dataset as a side effect
+ *
+ * @param array $subGraph
+ */
+ public function addGraph(array &$subGraph)
+ {
+ foreach ($subGraph['data'] as &$point) {
+ $x = $point[0];
+ if (!isset($this->points[$x])) {
+ $this->points[$x] = 0;
+ }
+ // store old y-value for displaying the actual (non-aggregated)
+ // value in the tooltip
+ $point[2] = $point[1];
+
+ $this->points[$x] += $point[1];
+ $point[1] = $this->points[$x];
+ }
+ }
+
+ /**
+ * Add a graph to the stack
+ *
+ * @param $graph
+ */
+ public function addToStack($graph)
+ {
+ $this->stack[] = $graph;
+ }
+
+ /**
+ * Empty the stack
+ *
+ * @return bool
+ */
+ public function stackEmpty()
+ {
+ return empty($this->stack);
+ }
+
+ /**
+ * Render this stack in the correct order
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement The SVG representation of this graph
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $group = $ctx->getDocument()->createElement('g');
+ $renderOrder = array_reverse($this->stack);
+ foreach ($renderOrder as $stackElem) {
+ $group->appendChild($stackElem->toSvg($ctx));
+ }
+ return $group;
+ }
+}
diff --git a/library/Icinga/Chart/Graph/Tooltip.php b/library/Icinga/Chart/Graph/Tooltip.php
new file mode 100644
index 0000000..7236685
--- /dev/null
+++ b/library/Icinga/Chart/Graph/Tooltip.php
@@ -0,0 +1,143 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Graph;
+
+/**
+ * A tooltip that stores and aggregates information about displayed data
+ * points of a graph and replaces them in a format string to render the description
+ * for specific data points of the graph.
+ *
+ * When render() is called, placeholders for the keys for each data entry will be replaced by
+ * the current value of this data set and the formatted string will be returned.
+ * The content of the replaced keys can change for each data set and depends on how the data
+ * is passed to this class. There are several types of properties:
+ *
+ * <ul>
+ * <li>Global properties</li>: Key-value pairs that stay the same every time render is called, and are
+ * passed to an instance in the constructor.
+ * <li>Aggregated properties</li>: Global properties that are created automatically from
+ * all attached data points.
+ * <li>Local properties</li>: Key-value pairs that only apply to a single data point and
+ * are passed to the render-function.
+ * </ul>
+ */
+class Tooltip
+{
+ /**
+ * The default format string used
+ * when no other format is specified
+ *
+ * @var string
+ */
+ private $defaultFormat;
+
+ /**
+ * All aggregated points
+ *
+ * @var array
+ */
+ private $points = array();
+
+ /**
+ * Contains all static replacements
+ *
+ * @var array
+ */
+ private $data = array(
+ 'sum' => 0
+ );
+
+ /**
+ * Used to format the displayed tooltip.
+ *
+ * @var string
+ */
+ protected $tooltipFormat;
+
+ /**
+ * Create a new tooltip with the specified default format string
+ *
+ * Allows you to set the global data for this tooltip, that is displayed every
+ * time render is called.
+ *
+ * @param array $data Map of global properties
+ * @param string $format The default format string
+ */
+ public function __construct(
+ $data = array(),
+ $format = '<b>{title}</b>: {value} {label}'
+ ) {
+ $this->data = array_merge($this->data, $data);
+ $this->defaultFormat = $format;
+ }
+
+ /**
+ * Add a single data point to update the aggregated properties for this tooltip
+ *
+ * @param $point array Contains the (x,y) values of the data set
+ */
+ public function addDataPoint($point)
+ {
+ // set x-value
+ if (!isset($this->data['title'])) {
+ $this->data['title'] = $point[0];
+ }
+
+ // aggregate y-values
+ $y = (int)$point[1];
+ if (isset($point[2])) {
+ // load original value in case value already aggregated
+ $y = (int)$point[2];
+ }
+
+ if (!isset($this->data['min']) || $this->data['min'] > $y) {
+ $this->data['min'] = $y;
+ }
+ if (!isset($this->data['max']) || $this->data['max'] < $y) {
+ $this->data['max'] = $y;
+ }
+ $this->data['sum'] += $y;
+ $this->points[] = $y;
+ }
+
+ /**
+ * Format the tooltip for a certain data point
+ *
+ * @param array $order Which data set to render
+ * @param array $data The local data for this tooltip
+ * @param string $format Use a custom format string for this data set
+ *
+ * @return mixed|string The tooltip value
+ */
+ public function render($order, $data = array(), $format = null)
+ {
+ if (isset($format)) {
+ $str = $format;
+ } else {
+ $str = $this->defaultFormat;
+ }
+ $data['value'] = $this->points[$order];
+ foreach (array_merge($this->data, $data) as $key => $value) {
+ $str = str_replace('{' . $key . '}', $value, $str);
+ }
+ return $str;
+ }
+
+ /**
+ * Format the tooltip for a certain data point but remove all
+ * occurring html tags
+ *
+ * This is useful for rendering clean tooltips on client without JavaScript
+ *
+ * @param array $order Which data set to render
+ * @param array $data The local data for this tooltip
+ * @param string $format Use a custom format string for this data set
+ *
+ * @return mixed|string The tooltip value, without any HTML tags
+ */
+ public function renderNoHtml($order, $data, $format)
+ {
+ return strip_tags($this->render($order, $data, $format));
+ }
+}
diff --git a/library/Icinga/Chart/GridChart.php b/library/Icinga/Chart/GridChart.php
new file mode 100644
index 0000000..a8cfca6
--- /dev/null
+++ b/library/Icinga/Chart/GridChart.php
@@ -0,0 +1,446 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use DOMElement;
+use Icinga\Chart\Chart;
+use Icinga\Chart\Axis;
+use Icinga\Chart\Graph\BarGraph;
+use Icinga\Chart\Graph\LineGraph;
+use Icinga\Chart\Graph\StackedGraph;
+use Icinga\Chart\Graph\Tooltip;
+use Icinga\Chart\Primitive\Canvas;
+use Icinga\Chart\Primitive\Rect;
+use Icinga\Chart\Primitive\Path;
+use Icinga\Chart\Render\LayoutBox;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Unit\AxisUnit;
+
+/**
+ * Base class for grid based charts.
+ *
+ * Allows drawing of Line and Barcharts. See the graphing documentation for further details.
+ *
+ * Example:
+ * <pre>
+ * <code>
+ * $this->chart = new GridChart();
+ * $this->chart->setAxisLabel("X axis label", "Y axis label");
+ * $this->chart->setXAxis(Axis::CalendarUnit());
+ * $this->chart->drawLines(
+ * array(
+ * 'data' => array(
+ * array(time()-7200, 10),array(time()-3620, 30), array(time()-1800, 15), array(time(), 92))
+ * )
+ * );
+ * </code>
+ * </pre>
+ */
+class GridChart extends Chart
+{
+ /**
+ * Internal identifier for Line Chart elements
+ */
+ const TYPE_LINE = "LINE";
+
+ /**
+ * Internal identifier fo Bar Chart elements
+ */
+ const TYPE_BAR = "BAR";
+
+ /**
+ * Internal array containing all elements to be drawn in the order they are drawn
+ *
+ * @var array
+ */
+ private $graphs = array();
+
+ /**
+ * An associative array containing all axis of this Chart in the "name" => Axis() form.
+ *
+ * Currently only the 'default' axis is really supported
+ *
+ * @var array
+ */
+ private $axis = array();
+
+ /**
+ * An associative array containing all StackedGraph objects used for cumulative graphs
+ *
+ * The array key is the 'stack' value given in the graph definitions
+ *
+ * @var array
+ */
+ private $stacks = array();
+
+ /**
+ * An associative array containing all Tooltips used to render the titles
+ *
+ * Each tooltip represents the summary for all y-values of a certain x-value
+ * in the grid chart
+ *
+ * @var Tooltip
+ */
+ private $tooltips = array();
+
+ public function __construct()
+ {
+ $this->title = t('Grid Chart');
+ $this->description = t('Contains data in a bar or line chart.');
+ parent::__construct();
+ }
+
+ /**
+ * Check if the current dataset has the proper structure for this chart.
+ *
+ * Needs to be overwritten by extending classes. The default implementation returns false.
+ *
+ * @return bool True when the dataset is valid, otherwise false
+ */
+ public function isValidDataFormat()
+ {
+ foreach ($this->graphs as $values) {
+ foreach ($values as $value) {
+ if (!isset($value['data']) || !is_array($value['data'])) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Calls Axis::addDataset for every graph added to this GridChart
+ *
+ * @see Axis::addDataset
+ */
+ private function configureAxisFromDatasets()
+ {
+ foreach ($this->graphs as $axis => &$graphs) {
+ $axisObj = $this->axis[$axis];
+ foreach ($graphs as &$graph) {
+ $axisObj->addDataset($graph);
+ }
+ }
+ }
+
+ /**
+ * Add an arbitrary number of lines to be drawn
+ *
+ * Refer to the graphs.md for a detailed list of allowed attributes
+ *
+ * @param array $axis,... The line definitions to draw
+ *
+ * @return $this Fluid interface
+ */
+ public function drawLines(array $axis)
+ {
+ $this->draw(self::TYPE_LINE, func_get_args());
+ return $this;
+ }
+
+ /**
+ * Add arbitrary number of bars to be drawn
+ *
+ * Refer to the graphs.md for a detailed list of allowed attributes
+ *
+ * @param array $axis
+ * @return $this
+ */
+ public function drawBars(array $axis)
+ {
+ $this->draw(self::TYPE_BAR, func_get_args());
+ return $this;
+ }
+
+ /**
+ * Generic method for adding elements to the drawing stack
+ *
+ * @param string $type The type of the element to draw (see TYPE_ constants in this class)
+ * @param array $data The data given to the draw call
+ */
+ private function draw($type, $data)
+ {
+ $axisName = 'default';
+ if (is_string($data[0])) {
+ $axisName = $data[0];
+ array_shift($data);
+ }
+ foreach ($data as &$graph) {
+ $graph['graphType'] = $type;
+ if (isset($graph['stack'])) {
+ if (!isset($this->stacks[$graph['stack']])) {
+ $this->stacks[$graph['stack']] = new StackedGraph();
+ }
+ $this->stacks[$graph['stack']]->addGraph($graph);
+ $graph['stack'] = $this->stacks[$graph['stack']];
+ }
+
+ if (!isset($graph['color'])) {
+ $colorType = isset($graph['palette']) ? $graph['palette'] : Palette::NEUTRAL;
+ $graph['color'] = $this->palette->getNext($colorType);
+ }
+ $this->graphs[$axisName][] = $graph;
+ if ($this->legend) {
+ $this->legend->addDataset($graph);
+ }
+ }
+ $this->initTooltips($data);
+ }
+
+
+ private function initTooltips($data)
+ {
+ foreach ($data as &$graph) {
+ foreach ($graph['data'] as $x => $point) {
+ if (!array_key_exists($x, $this->tooltips)) {
+ $this->tooltips[$x] = new Tooltip(
+ array(
+ 'color' => $graph['color'],
+
+ )
+ );
+ }
+ $this->tooltips[$x]->addDataPoint($point);
+ }
+ }
+ }
+
+ /**
+ * Set the label for the x and y axis
+ *
+ * @param string $xAxisLabel The label to use for the x axis
+ * @param string $yAxisLabel The label to use for the y axis
+ * @param string $axisName The name of the axis, for now 'default'
+ *
+ * @return $this Fluid interface
+ */
+ public function setAxisLabel($xAxisLabel, $yAxisLabel, $axisName = 'default')
+ {
+ $this->axis[$axisName]->setXLabel($xAxisLabel)->setYLabel($yAxisLabel);
+ return $this;
+ }
+
+ /**
+ * Set the AxisUnit to use for calculating the values of the x axis
+ *
+ * @param AxisUnit $unit The unit for the x axis
+ * @param string $axisName The name of the axis to set the label for, currently only 'default'
+ *
+ * @return $this Fluid interface
+ */
+ public function setXAxis(AxisUnit $unit, $axisName = 'default')
+ {
+ $this->axis[$axisName]->setUnitForXAxis($unit);
+ return $this;
+ }
+
+ /**
+ * Set the AxisUnit to use for calculating the values of the y axis
+ *
+ * @param AxisUnit $unit The unit for the y axis
+ * @param string $axisName The name of the axis to set the label for, currently only 'default'
+ *
+ * @return $this Fluid interface
+ */
+ public function setYAxis(AxisUnit $unit, $axisName = 'default')
+ {
+ $this->axis[$axisName]->setUnitForYAxis($unit);
+ return $this;
+ }
+
+ /**
+ * Pre-render setup of the axis
+ *
+ * @see Chart::build
+ */
+ protected function build()
+ {
+ $this->configureAxisFromDatasets();
+ }
+
+ /**
+ * Initialize the renderer and overwrite it with an 2:1 ration renderer
+ */
+ protected function init()
+ {
+ $this->renderer = new SVGRenderer(100, 100);
+ $this->setAxis(Axis::createLinearAxis());
+ }
+
+ /**
+ * Overwrite the axis to use
+ *
+ * @param Axis $axis The new axis to use
+ * @param string $name The name of the axis, currently only 'default'
+ *
+ * @return $this Fluid interface
+ */
+ public function setAxis(Axis $axis, $name = 'default')
+ {
+ $this->axis = array($name => $axis);
+ return $this;
+ }
+
+ /**
+ * Add an axis to this graph (not really supported right now)
+ *
+ * @param Axis $axis The axis object to add
+ * @param string $name The name of the axis
+ *
+ * @return $this Fluid interface
+ */
+ public function addAxis(Axis $axis, $name)
+ {
+ $this->axis[$name] = $axis;
+ return $this;
+ }
+
+ /**
+ * Set minimum values for the x and y axis.
+ *
+ * Setting null to an axis means this will use a value determined by the dataset
+ *
+ * @param int $xMin The minimum value for the x axis or null to use a dynamic value
+ * @param int $yMin The minimum value for the y axis or null to use a dynamic value
+ * @param string $axisName The name of the axis to set the minimum, currently only 'default'
+ *
+ * @return $this Fluid interface
+ */
+ public function setAxisMin($xMin = null, $yMin = null, $axisName = 'default')
+ {
+ $this->axis[$axisName]->setXMin($xMin)->setYMin($yMin);
+ return $this;
+ }
+
+ /**
+ * Set maximum values for the x and y axis.
+ *
+ * Setting null to an axis means this will use a value determined by the dataset
+ *
+ * @param int $xMax The maximum value for the x axis or null to use a dynamic value
+ * @param int $yMax The maximum value for the y axis or null to use a dynamic value
+ * @param string $axisName The name of the axis to set the maximum, currently only 'default'
+ *
+ * @return $this Fluid interface
+ */
+ public function setAxisMax($xMax = null, $yMax = null, $axisName = 'default')
+ {
+ $this->axis[$axisName]->setXMax($xMax)->setYMax($yMax);
+ return $this;
+ }
+
+ /**
+ * Render this GridChart to SVG
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $outerBox = new Canvas('outerGraph', new LayoutBox(0, 0, 100, 100));
+ $innerBox = new Canvas('graph', new LayoutBox(0, 0, 95, 90));
+
+ $maxPadding = array(0,0,0,0);
+ foreach ($this->axis as $axis) {
+ $padding = $axis->getRequiredPadding();
+ for ($i=0; $i < count($padding); $i++) {
+ $maxPadding[$i] = max($maxPadding[$i], $padding[$i]);
+ }
+ $innerBox->addElement($axis);
+ }
+ $this->renderGraphContent($innerBox);
+
+ $innerBox->getLayout()->setPadding($maxPadding[0], $maxPadding[1], $maxPadding[2], $maxPadding[3]);
+ $this->createContentClipBox($innerBox);
+
+ $outerBox->addElement($innerBox);
+ if ($this->legend) {
+ $outerBox->addElement($this->legend);
+ }
+ return $outerBox->toSvg($ctx);
+ }
+
+ /**
+ * Create a clip box that defines which area of the graph is drawable and adds it to the graph.
+ *
+ * The clipbox has the id '#clip' and can be used in the clip-mask element
+ *
+ * @param Canvas $innerBox The inner canvas of the graph to add the clip box to
+ */
+ private function createContentClipBox(Canvas $innerBox)
+ {
+ $clipBox = new Canvas('clip', new LayoutBox(0, 0, 100, 100));
+ $clipBox->toClipPath();
+ $innerBox->addElement($clipBox);
+ $rect = new Rect(0.1, 0, 100, 99.9);
+ $clipBox->addElement($rect);
+ }
+
+ /**
+ * Render the content of the graph, i.e. the draw stack
+ *
+ * @param Canvas $innerBox The inner canvas of the graph to add the content to
+ */
+ private function renderGraphContent(Canvas $innerBox)
+ {
+ foreach ($this->graphs as $axisName => $graphs) {
+ $axis = $this->axis[$axisName];
+ $graphObj = null;
+ foreach ($graphs as $dataset => $graph) {
+ // determine the type and create a graph object for it
+ switch ($graph['graphType']) {
+ case self::TYPE_BAR:
+ $graphObj = new BarGraph(
+ $axis->transform($graph['data']),
+ $graphs,
+ $dataset,
+ $this->tooltips
+ );
+ break;
+ case self::TYPE_LINE:
+ $graphObj = new LineGraph(
+ $axis->transform($graph['data']),
+ $graphs,
+ $dataset,
+ $this->tooltips
+ );
+ break;
+ default:
+ continue 2;
+ }
+ $el = $this->setupGraph($graphObj, $graph);
+ if ($el) {
+ $innerBox->addElement($el);
+ }
+ }
+ }
+ }
+
+ /**
+ * Setup the provided Graph type
+ *
+ * @param mixed $graphObject The graph class, needs the setStyleFromConfig method
+ * @param array $graphConfig The configration array of the graph
+ *
+ * @return mixed Either the graph to be added or null if the graph is not directly added
+ * to the document (e.g. stacked graphs are added by
+ * the StackedGraph Composite object)
+ */
+ private function setupGraph($graphObject, array $graphConfig)
+ {
+ $graphObject->setStyleFromConfig($graphConfig);
+ // When in a stack return the StackedGraph object instead of the graphObject
+ if (isset($graphConfig['stack'])) {
+ $graphConfig['stack']->addToStack($graphObject);
+ if (!$graphConfig['stack']->stackEmpty()) {
+ return $graphConfig['stack'];
+ }
+ // return no object when the graph should not be rendered
+ return null;
+ }
+ return $graphObject;
+ }
+}
diff --git a/library/Icinga/Chart/Inline/Inline.php b/library/Icinga/Chart/Inline/Inline.php
new file mode 100644
index 0000000..3acbd73
--- /dev/null
+++ b/library/Icinga/Chart/Inline/Inline.php
@@ -0,0 +1,96 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Inline;
+
+/**
+ * Class to render and inline chart directly from the request params.
+ *
+ * When rendering huge amounts of inline charts it is too expensive
+ * to bootstrap the complete application for ever single chart and
+ * we need to be able render Charts in a compact environment without
+ * the other Icinga classes.
+ *
+ * Class Inline
+ * @package Icinga\Chart\Inline
+ */
+class Inline
+{
+
+ /**
+ * The data displayed in this chart
+ *
+ * @var array
+ */
+ protected $data;
+
+ /**
+ * The colors used to display this chart
+ *
+ * @var array
+ */
+ protected $colors = array(
+ '#00FF00', // OK
+ '#FFFF00', // Warning
+ '#FF0000', // Critical
+ '#E066FF' // Unreachable
+ );
+
+ /**
+ * The labels displayed on this chart
+ *
+ * @var array
+ */
+ protected $labels = array();
+
+ /**
+ * The height in percent
+ *
+ * @var int
+ */
+ protected $height = 100;
+
+ /**
+ * The width in percent
+ *
+ * @var int
+ */
+ protected $width = 100;
+
+ protected function sanitizeStringArray(array $arr)
+ {
+ $sanitized = array();
+ foreach ($arr as $key => $value) {
+ $sanitized[$key] = htmlspecialchars($value);
+ }
+ return $sanitized;
+ }
+
+ /**
+ * Populate the properties from the current request.
+ */
+ public function initFromRequest()
+ {
+ $this->data = explode(',', $_GET['data']);
+ foreach ($this->data as $key => $value) {
+ $this->data[$key] = (int)$value;
+ }
+ for ($i = 0; $i < count($this->data); $i++) {
+ $this->labels[] = '';
+ }
+
+ if (array_key_exists('colors', $_GET)) {
+ $this->colors = $this->sanitizeStringArray(explode(',', $_GET['colors']));
+ }
+ while (count($this->colors) < count($this->data)) {
+ $this->colors[] = '#FEFEFE';
+ }
+
+ if (array_key_exists('width', $_GET)) {
+ $this->width = (int)$_GET['width'];
+ }
+ if (array_key_exists('height', $_GET)) {
+ $this->height = (int)$_GET['height'];
+ }
+ }
+}
diff --git a/library/Icinga/Chart/Inline/PieChart.php b/library/Icinga/Chart/Inline/PieChart.php
new file mode 100644
index 0000000..de68213
--- /dev/null
+++ b/library/Icinga/Chart/Inline/PieChart.php
@@ -0,0 +1,41 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Inline;
+
+use Icinga\Chart\PieChart as PieChartRenderer;
+
+/**
+ * Draw an inline pie-chart directly from the available request parameters.
+ */
+class PieChart extends Inline
+{
+ protected function getChart()
+ {
+ $pie = new PieChartRenderer();
+ $pie->alignTopLeft();
+ $pie->disableLegend();
+ $pie->drawPie(array(
+ 'data' => $this->data, 'colors' => $this->colors, 'labels' => $this->labels
+ ));
+ return $pie;
+ }
+
+ public function toSvg($output = true)
+ {
+ if ($output) {
+ echo $this->getChart()->render();
+ } else {
+ return $this->getChart()->render();
+ }
+ }
+
+ public function toPng($output = true)
+ {
+ if ($output) {
+ echo $this->getChart()->toPng($this->width, $this->height);
+ } else {
+ return $this->getChart()->toPng($this->width, $this->height);
+ }
+ }
+}
diff --git a/library/Icinga/Chart/Legend.php b/library/Icinga/Chart/Legend.php
new file mode 100644
index 0000000..ab1c9e0
--- /dev/null
+++ b/library/Icinga/Chart/Legend.php
@@ -0,0 +1,102 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use DOMElement;
+use Icinga\Chart\Palette;
+use Icinga\Chart\Primitive\Canvas;
+use Icinga\Chart\Primitive\Drawable;
+use Icinga\Chart\Primitive\Rect;
+use Icinga\Chart\Primitive\Text;
+use Icinga\Chart\Render\LayoutBox;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Drawable for creating a Graph Legend on the bottom of a graph.
+ *
+ * Usually used by the GridChart class internally.
+ */
+class Legend implements Drawable
+{
+
+ /**
+ * Internal counter for unnamed label identifiers
+ *
+ * @var int
+ */
+ private $internalCtr = 0;
+
+ /**
+ *
+ * Content of this legend
+ *
+ * @var array
+ */
+ private $dataset = array();
+
+
+ /**
+ * Set the content to be displayed by this legend
+ *
+ * @param array $dataset An array of datasets in the form they are provided to the graphing implementation
+ */
+ public function addDataset(array $dataset)
+ {
+ if (!isset($dataset['label'])) {
+ $dataset['label'] = 'Dataset ' . (++$this->internalCtr);
+ }
+ if (!isset($dataset['color'])) {
+ return;
+ }
+ $this->dataset[$dataset['color']] = $dataset['label'];
+ }
+
+ /**
+ * Render the legend to an SVG object
+ *
+ * @param RenderContext $ctx The context to use for rendering this legend
+ *
+ * @return DOMElement The SVG representation of this legend
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $outer = new Canvas('legend', new LayoutBox(0, 0, 100, 100));
+ $outer->getLayout()->setPadding(2, 2, 2, 2);
+ $nrOfColumns = 4;
+
+ $topstep = 10 / $nrOfColumns + 2;
+
+ $top = 0;
+ $left = 0;
+ $lastLabelEndPos = -1;
+ foreach ($this->dataset as $color => $text) {
+ $leftstep = 100 / $nrOfColumns + strlen($text);
+
+ // Make sure labels don't overlap each other
+ while ($lastLabelEndPos >= $left) {
+ $left += $leftstep;
+ }
+ // When a label is longer than the available space, use the next line
+ if ($left + strlen($text) > 100) {
+ $top += $topstep;
+ $left = 0;
+ }
+
+ $colorBox = new Rect($left, $top, 2, 2);
+ $colorBox->setFill($color)->setStrokeWidth(2);
+ $colorBox->keepRatio();
+ $outer->addElement($colorBox);
+
+ $textBox = new Text($left+5, $top+2, $text);
+ $textBox->setFontSize('2em');
+ $outer->addElement($textBox);
+
+ // readjust layout
+ $lastLabelEndPos = $left + strlen($text);
+ $left += $leftstep;
+ }
+ $svg = $outer->toSvg($ctx);
+ return $svg;
+ }
+}
diff --git a/library/Icinga/Chart/Palette.php b/library/Icinga/Chart/Palette.php
new file mode 100644
index 0000000..90ad74b
--- /dev/null
+++ b/library/Icinga/Chart/Palette.php
@@ -0,0 +1,65 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+/**
+ * Provide a set of colors that will be used by the chart as default values
+ */
+class Palette
+{
+ /**
+ * Neutral colors without special meaning
+ */
+ const NEUTRAL = 'neutral';
+
+ /**
+ * A set of problem (i.e. red) colors
+ */
+ const PROBLEM = 'problem';
+
+ /**
+ * A set of ok (i.e. green) colors
+ */
+ const OK = 'ok';
+
+ /**
+ * A set of warning (i.e. yellow) colors
+ */
+ const WARNING = 'warning';
+
+ /**
+ * The colorsets for specific categories
+ *
+ * @var array
+ */
+ public $colorSets = array(
+ self::OK => array('#00FF00'),
+ self::PROBLEM => array('#FF0000'),
+ self::WARNING => array('#FFFF00'),
+ self::NEUTRAL => array('#f3f3f3')
+ );
+
+ /**
+ * Return the next available color as an hex string for the given type
+ *
+ * @param string $type The type to receive a color from
+ *
+ * @return string The color in hex format
+ */
+ public function getNext($type = self::NEUTRAL)
+ {
+ if (!isset($this->colorSets[$type])) {
+ $type = self::NEUTRAL;
+ }
+
+ $color = current($this->colorSets[$type]);
+ if ($color === false) {
+ reset($this->colorSets[$type]);
+
+ $color = current($this->colorSets[$type]);
+ }
+ next($this->colorSets[$type]);
+ return $color;
+ }
+}
diff --git a/library/Icinga/Chart/PieChart.php b/library/Icinga/Chart/PieChart.php
new file mode 100644
index 0000000..1bcf380
--- /dev/null
+++ b/library/Icinga/Chart/PieChart.php
@@ -0,0 +1,306 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use DOMElement;
+use Icinga\Chart\Chart;
+use Icinga\Chart\Primitive\Canvas;
+use Icinga\Chart\Primitive\PieSlice;
+use Icinga\Chart\Primitive\RawElement;
+use Icinga\Chart\Primitive\Rect;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Render\LayoutBox;
+
+/**
+ * Graphing component for rendering Pie Charts.
+ *
+ * See the graphs.md documentation for further information about how to use this component
+ */
+class PieChart extends Chart
+{
+ /**
+ * Stack multiple pies
+ */
+ const STACKED = "stacked";
+
+ /**
+ * Draw multiple pies beneath each other
+ */
+ const ROW = "row";
+
+ /**
+ * The drawing stack containing all pie definitions in the order they will be drawn
+ *
+ * @var array
+ */
+ private $pies = array();
+
+ /**
+ * The composition type currently used
+ *
+ * @var string
+ */
+ private $type = PieChart::STACKED;
+
+ /**
+ * Disable drawing of captions when set true
+ *
+ * @var bool
+ */
+ private $noCaption = false;
+
+ public function __construct()
+ {
+ $this->title = t('Pie Chart');
+ $this->description = t('Contains data in a pie chart.');
+ parent::__construct();
+ }
+
+ /**
+ * Test if the given pies have the correct format
+ *
+ * @return bool True when the given pies are correct, otherwise false
+ */
+ public function isValidDataFormat()
+ {
+ foreach ($this->pies as $pie) {
+ if (!isset($pie['data']) || !is_array($pie['data'])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Create renderer and normalize the dataset to represent percentage information
+ */
+ protected function build()
+ {
+ $this->renderer = new SVGRenderer(($this->type === self::STACKED) ? 1 : count($this->pies), 1);
+ foreach ($this->pies as &$pie) {
+ $this->normalizeDataSet($pie);
+ }
+ }
+
+ /**
+ * Normalize the given dataset to represent percentage information instead of absolute valuess
+ *
+ * @param array $pie The pie definition given in the drawPie call
+ */
+ private function normalizeDataSet(&$pie)
+ {
+ $total = array_sum($pie['data']);
+ if ($total === 100) {
+ return;
+ }
+ if ($total == 0) {
+ return;
+ }
+ foreach ($pie['data'] as &$slice) {
+ $slice = $slice/$total * 100;
+ }
+ }
+
+ /**
+ * Draw an arbitrary number of pies in this chart
+ *
+ * @param array $dataSet,... The pie definition, see graphs.md for further details concerning the format
+ *
+ * @return $this Fluent interface
+ */
+ public function drawPie(array $dataSet)
+ {
+ $dataSets = func_get_args();
+ $this->pies += $dataSets;
+ foreach ($dataSets as $dataSet) {
+ $this->legend->addDataset($dataSet);
+ }
+ return $this;
+ }
+
+ /**
+ * Return the SVG representation of this graph
+ *
+ * @param RenderContext $ctx The context to use for drawings
+ *
+ * @return DOMElement The SVG representation of this graph
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $labelBox = $ctx->getDocument()->createElement('g');
+ if (!$this->noCaption) {
+ // Scale SVG to make room for captions
+ $outerBox = new Canvas('outerGraph', new LayoutBox(33, -5, 40, 40));
+ $innerBox = new Canvas('graph', new LayoutBox(0, 0, 100, 100));
+ $innerBox->getLayout()->setPadding(10, 10, 10, 10);
+ } else {
+ $outerBox = new Canvas('outerGraph', new LayoutBox(1.5, -10, 124, 124));
+ $innerBox = new Canvas('graph', new LayoutBox(0, 0, 100, 100));
+ $innerBox->getLayout()->setPadding(0, 0, 0, 0);
+ }
+ $this->createContentClipBox($innerBox);
+ $this->renderPies($innerBox, $labelBox);
+ $innerBox->addElement(new RawElement($labelBox));
+ $outerBox->addElement($innerBox);
+
+ return $outerBox->toSvg($ctx);
+ }
+
+ /**
+ * Render the pies in the draw stack using the selected algorithm for composition
+ *
+ * @param Canvas $innerBox The canvas to use for inserting the pies
+ * @param DOMElement $labelBox The DOM element to add the labels to (so they can't be overlapped by pie elements)
+ */
+ private function renderPies(Canvas $innerBox, DOMElement $labelBox)
+ {
+ if ($this->type === self::STACKED) {
+ $this->renderStackedPie($innerBox, $labelBox);
+ } else {
+ $this->renderPieRow($innerBox, $labelBox);
+ }
+ }
+
+ /**
+ * Return the color to be used for the given pie slice
+ *
+ * @param array $pie The pie configuration as provided in the drawPie call
+ * @param int $dataIdx The index of the pie slice in the pie configuration
+ *
+ * @return string The hex color string to use for the pie slice
+ */
+ private function getColorForPieSlice(array $pie, $dataIdx)
+ {
+ if (isset($pie['colors']) && is_array($pie['colors']) && isset($pie['colors'][$dataIdx])) {
+ return $pie['colors'][$dataIdx];
+ }
+ $type = Palette::NEUTRAL;
+ if (isset($pie['palette']) && is_array($pie['palette']) && isset($pie['palette'][$dataIdx])) {
+ $type = $pie['palette'][$dataIdx];
+ }
+ return $this->palette->getNext($type);
+ }
+
+ /**
+ * Render a row of pies
+ *
+ * @param Canvas $innerBox The canvas to insert the pies to
+ * @param DOMElement $labelBox The DOMElement to use for adding label elements
+ */
+ private function renderPieRow(Canvas $innerBox, DOMElement $labelBox)
+ {
+ $radius = 50 / count($this->pies);
+ $x = $radius;
+ foreach ($this->pies as $pie) {
+ $labelPos = 0;
+ $lastRadius = 0;
+
+ foreach ($pie['data'] as $idx => $dataset) {
+ $slice = new PieSlice($radius, $dataset, $lastRadius);
+ $slice->setX($x)
+ ->setStrokeColor('#000')
+ ->setStrokeWidth(1)
+ ->setY(50)
+ ->setFill($this->getColorForPieSlice($pie, $idx));
+ $innerBox->addElement($slice);
+ // add caption if not disabled
+ if (!$this->noCaption && isset($pie['labels'])) {
+ $slice->setCaption($pie['labels'][$labelPos++])
+ ->setLabelGroup($labelBox);
+ }
+ $lastRadius += $dataset;
+ }
+ // shift right for next pie
+ $x += $radius*2;
+ }
+ }
+
+ /**
+ * Render pies in a stacked way so one pie is nested in the previous pie
+ *
+ * @param Canvas $innerBox The canvas to insert the pie to
+ * @param DOMElement $labelBox The DOMElement to use for adding label elements
+ */
+ private function renderStackedPie(Canvas $innerBox, DOMElement $labelBox)
+ {
+ $radius = 40;
+ $minRadius = 20;
+ if (count($this->pies) == 0) {
+ return;
+ }
+ $shrinkStep = ($radius - $minRadius) / count($this->pies);
+ $x = $radius;
+
+ for ($i = 0; $i < count($this->pies); $i++) {
+ $pie = $this->pies[$i];
+ // the offset for the caption path, outer caption indicator shouldn't point
+ // to the middle of the slice as there will be another pie
+ $offset = isset($this->pies[$i+1]) ? $radius - $shrinkStep : 0;
+ $labelPos = 0;
+ $lastRadius = 0;
+ foreach ($pie['data'] as $idx => $dataset) {
+ $color = $this->getColorForPieSlice($pie, $idx);
+ if ($dataset == 0) {
+ $labelPos++;
+ continue;
+ }
+ $slice = new PieSlice($radius, $dataset, $lastRadius);
+ $slice->setY(50)
+ ->setX($x)
+ ->setStrokeColor('#000')
+ ->setStrokeWidth(1)
+ ->setFill($color)
+ ->setLabelGroup($labelBox);
+
+ if (!$this->noCaption && isset($pie['labels'])) {
+ $slice->setCaption($pie['labels'][$labelPos++])
+ ->setCaptionOffset($offset)
+ ->setOuterCaptionBound(50);
+ }
+ $innerBox->addElement($slice);
+ $lastRadius += $dataset;
+ }
+ // shrinken the next pie
+ $radius -= $shrinkStep;
+ }
+ }
+
+ /**
+ * Set the composition type of this PieChart
+ *
+ * @param string $type Either self::STACKED or self::ROW
+ *
+ * @return $this Fluent interface
+ */
+ public function setType($type)
+ {
+ $this->type = $type;
+ return $this;
+ }
+
+ /**
+ * Hide the caption from this PieChart
+ *
+ * @return $this Fluent interface
+ */
+ public function disableLegend()
+ {
+ $this->noCaption = true;
+ return $this;
+ }
+
+ /**
+ * Create the content for this PieChart
+ *
+ * @param Canvas $innerBox The innerbox to add the clip mask to
+ */
+ private function createContentClipBox(Canvas $innerBox)
+ {
+ $clipBox = new Canvas('clip', new LayoutBox(0, 0, 100, 100));
+ $clipBox->toClipPath();
+ $innerBox->addElement($clipBox);
+ $rect = new Rect(0.1, 0, 100, 99.9);
+ $clipBox->addElement($rect);
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Animatable.php b/library/Icinga/Chart/Primitive/Animatable.php
new file mode 100644
index 0000000..69ba0e1
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Animatable.php
@@ -0,0 +1,43 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Base interface for animatable objects
+ */
+abstract class Animatable extends Styleable
+{
+ /**
+ * The animation object set
+ *
+ * @var Animation
+ */
+ public $animation = null;
+
+ /**
+ * Set the animation for this object
+ *
+ * @param Animation $anim The animation to use
+ */
+ public function setAnimation(Animation $anim)
+ {
+ $this->animation = $anim;
+ }
+
+ /**
+ * Append the animation to the given element
+ *
+ * @param DOMElement $dom The element to append the animation to
+ * @param RenderContext $ctx The context to use for rendering the animation object
+ */
+ protected function appendAnimation(DOMElement $dom, RenderContext $ctx)
+ {
+ if ($this->animation) {
+ $dom->appendChild($this->animation->toSvg($ctx));
+ }
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Animation.php b/library/Icinga/Chart/Primitive/Animation.php
new file mode 100644
index 0000000..e620fa7
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Animation.php
@@ -0,0 +1,87 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Drawable for the SVG animate tag
+ */
+class Animation implements Drawable
+{
+ /**
+ * The attribute to animate
+ *
+ * @var string
+ */
+ private $attribute;
+
+ /**
+ * The 'from' value
+ *
+ * @var mixed
+ */
+ private $from;
+
+ /**
+ * The to value
+ *
+ * @var mixed
+ */
+ private $to;
+
+ /**
+ * The begin value (in seconds)
+ *
+ * @var float
+ */
+ private $begin = 0;
+
+ /**
+ * The duration value (in seconds)
+ *
+ * @var float
+ */
+ private $duration = 0.5;
+
+ /**
+ * Create an animation object
+ *
+ * @param string $attribute The attribute to animate
+ * @param string $from The from value for the animation
+ * @param string $to The to value for the animation
+ * @param float $duration The duration of the duration
+ * @param float $begin The begin of the duration
+ */
+ public function __construct($attribute, $from, $to, $duration = 0.5, $begin = 0.0)
+ {
+ $this->attribute = $attribute;
+ $this->from = $from;
+ $this->to = $to;
+ $this->duration = $duration;
+ $this->begin = $begin;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+
+ $animate = $ctx->getDocument()->createElement('animate');
+ $animate->setAttribute('attributeName', $this->attribute);
+ $animate->setAttribute('attributeType', 'XML');
+ $animate->setAttribute('from', $this->from);
+ $animate->setAttribute('to', $this->to);
+ $animate->setAttribute('begin', $this->begin . 's');
+ $animate->setAttribute('dur', $this->duration . 's');
+ $animate->setAttribute('fill', "freeze");
+
+ return $animate;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Canvas.php b/library/Icinga/Chart/Primitive/Canvas.php
new file mode 100644
index 0000000..32f06bf
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Canvas.php
@@ -0,0 +1,140 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Primitive;
+
+use DOMElement;
+use Icinga\Chart\Render\LayoutBox;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Canvas SVG component that encapsulates grouping and padding and allows rendering
+ * multiple elements in a group
+ *
+ */
+class Canvas implements Drawable
+{
+ /**
+ * The name of the canvas, will be used as the id
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * An array of child elements of this Canvas
+ *
+ * @var array
+ */
+ private $children = array();
+
+ /**
+ * When true, this canvas is encapsulated in a clipPath tag and not drawn
+ *
+ * @var bool
+ */
+ private $isClipPath = false;
+
+ /**
+ * The LayoutBox of this Canvas
+ *
+ * @var LayoutBox
+ */
+ private $rect;
+
+ /**
+ * The aria role used to describe this canvas' purpose in the accessibility tree
+ *
+ * @var string
+ */
+ private $ariaRole;
+
+ /**
+ * Create this canvas
+ *
+ * @param String $name The name of this canvas
+ * @param LayoutBox $rect The layout and size of this canvas
+ */
+ public function __construct($name, LayoutBox $rect)
+ {
+ $this->rect = $rect;
+ $this->name = $name;
+ }
+
+ /**
+ * Convert this canvas to a clipPath element
+ */
+ public function toClipPath()
+ {
+ $this->isClipPath = true;
+ }
+
+ /**
+ * Return the layout of this canvas
+ *
+ * @return LayoutBox
+ */
+ public function getLayout()
+ {
+ return $this->rect;
+ }
+
+ /**
+ * Add an element to this canvas
+ *
+ * @param Drawable $child
+ */
+ public function addElement(Drawable $child)
+ {
+ $this->children[] = $child;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $doc = $ctx->getDocument();
+ if ($this->isClipPath) {
+ $outer = $doc->createElement('defs');
+ $innerContainer = $element = $doc->createElement('clipPath');
+ $outer->appendChild($element);
+ } else {
+ $outer = $element = $doc->createElement('g');
+ $innerContainer = $doc->createElement('g');
+ $innerContainer->setAttribute('x', 0);
+ $innerContainer->setAttribute('y', 0);
+ $innerContainer->setAttribute('id', $this->name . '_inner');
+ $innerContainer->setAttribute('transform', $this->rect->getInnerTransform($ctx));
+ $element->appendChild($innerContainer);
+ }
+
+ $element->setAttribute('id', $this->name);
+ foreach ($this->children as $child) {
+ $innerContainer->appendChild($child->toSvg($ctx));
+ }
+
+ if (isset($this->ariaRole)) {
+ $outer->setAttribute('role', $this->ariaRole);
+ }
+ return $outer;
+ }
+
+ /**
+ * Set the aria role used to determine the meaning of this canvas in the accessibility tree
+ *
+ * The role 'presentation' will indicate that the purpose of this canvas is entirely decorative, while the role
+ * 'img' will indicate that the canvas contains an image, with a possible title or a description. For other
+ * possible roles, see http://www.w3.org/TR/wai-aria/roles
+ *
+ * @param $role string The aria role to set
+ */
+ public function setAriaRole($role)
+ {
+ $this->ariaRole = $role;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Circle.php b/library/Icinga/Chart/Primitive/Circle.php
new file mode 100644
index 0000000..f98ffac
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Circle.php
@@ -0,0 +1,84 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Primitive;
+
+use DOMDocument;
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Format;
+
+/**
+ * Drawable for svg circles
+ */
+class Circle extends Styleable implements Drawable
+{
+ /**
+ * The circles x position
+ *
+ * @var int
+ */
+ private $x;
+
+ /**
+ * The circles y position
+ *
+ * @var int
+ */
+ private $y;
+
+ /**
+ * The circles radius
+ *
+ * @var int
+ */
+ private $radius;
+
+ /**
+ * Construct the circle
+ *
+ * @param int $x The x position of the circle
+ * @param int $y The y position of the circle
+ * @param int $radius The radius of the circle
+ */
+ public function __construct($x, $y, $radius)
+ {
+ $this->x = $x;
+ $this->y = $y;
+ $this->radius = $radius;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $coords = $ctx->toAbsolute($this->x, $this->y);
+ $circle = $ctx->getDocument()->createElement('circle');
+ $circle->setAttribute('cx', Format::formatSVGNumber($coords[0]));
+ $circle->setAttribute('cy', Format::formatSVGNumber($coords[1]));
+ $circle->setAttribute('r', $this->radius);
+
+ $id = $this->id ?? uniqid('circle-');
+ $circle->setAttribute('id', $id);
+ $this->setId($id);
+
+ $this->applyAttributes($circle);
+
+ $style = new DOMDocument();
+ $style->loadHTML($this->getStyle());
+
+ $circle->appendChild(
+ $circle->ownerDocument->importNode(
+ $style->getElementsByTagName('style')->item(0),
+ true
+ )
+ );
+
+ return $circle;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Drawable.php b/library/Icinga/Chart/Primitive/Drawable.php
new file mode 100644
index 0000000..5b4355c
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Drawable.php
@@ -0,0 +1,22 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Drawable element for creating svg out of components
+ */
+interface Drawable
+{
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx);
+}
diff --git a/library/Icinga/Chart/Primitive/Line.php b/library/Icinga/Chart/Primitive/Line.php
new file mode 100644
index 0000000..d83cbea
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Line.php
@@ -0,0 +1,103 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMDocument;
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Format;
+
+/**
+ * Drawable for the svg line element
+ */
+class Line extends Styleable implements Drawable
+{
+
+ /**
+ * The default stroke width
+ *
+ * @var int
+ */
+ public $strokeWidth = 1;
+
+ /**
+ * The line's start x coordinate
+ *
+ * @var int
+ */
+ private $xStart = 0;
+
+ /**
+ * The line's end x coordinate
+ *
+ * @var int
+ */
+ private $xEnd = 0;
+
+ /**
+ * The line's start y coordinate
+ *
+ * @var int
+ */
+ private $yStart = 0;
+
+ /**
+ * The line's end y coordinate
+ *
+ * @var int
+ */
+ private $yEnd = 0;
+
+ /**
+ * Create a line object starting at the first coordinate and ending at the second one
+ *
+ * @param int $x1 The line's start x coordinate
+ * @param int $y1 The line's start y coordinate
+ * @param int $x2 The line's end x coordinate
+ * @param int $y2 The line's end y coordinate
+ */
+ public function __construct($x1, $y1, $x2, $y2)
+ {
+ $this->xStart = $x1;
+ $this->xEnd = $x2;
+ $this->yStart = $y1;
+ $this->yEnd = $y2;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $doc = $ctx->getDocument();
+ list($x1, $y1) = $ctx->toAbsolute($this->xStart, $this->yStart);
+ list($x2, $y2) = $ctx->toAbsolute($this->xEnd, $this->yEnd);
+ $line = $doc->createElement('line');
+ $line->setAttribute('x1', Format::formatSVGNumber($x1));
+ $line->setAttribute('x2', Format::formatSVGNumber($x2));
+ $line->setAttribute('y1', Format::formatSVGNumber($y1));
+ $line->setAttribute('y2', Format::formatSVGNumber($y2));
+
+ $id = $this->id ?? uniqid('line-');
+ $line->setAttribute('id', $id);
+ $this->setId($id);
+
+ $this->applyAttributes($line);
+
+ $style = new DOMDocument();
+ $style->loadHTML($this->getStyle());
+
+ $line->appendChild(
+ $line->ownerDocument->importNode(
+ $style->getElementsByTagName('style')->item(0),
+ true
+ )
+ );
+
+ return $line;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Path.php b/library/Icinga/Chart/Primitive/Path.php
new file mode 100644
index 0000000..b9d5f7b
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Path.php
@@ -0,0 +1,187 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMDocument;
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Format;
+
+/**
+ * Drawable for creating a svg path element
+ */
+class Path extends Styleable implements Drawable
+{
+ /**
+ * Syntax template for moving
+ *
+ * @see http://www.w3.org/TR/SVG/paths.html#PathDataMovetoCommands
+ */
+ const TPL_MOVE = 'M %s %s ';
+
+ /**
+ * Syntax template for bezier curve
+ *
+ * @see http://www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands
+ */
+ const TPL_BEZIER = 'S %s %s ';
+
+ /**
+ * Syntax template for straight lines
+ *
+ * @see http://www.w3.org/TR/SVG/paths.html#PathDataLinetoCommands
+ */
+ const TPL_STRAIGHT = 'L %s %s ';
+
+ /**
+ * The default stroke width
+ *
+ * @var int
+ */
+ public $strokeWidth = 1;
+
+ /**
+ * True to treat coordinates as absolute values
+ *
+ * @var bool
+ */
+ protected $isAbsolute = false;
+
+ /**
+ * The points to draw, in the order they are drawn
+ *
+ * @var array
+ */
+ protected $points = array();
+
+ /**
+ * True to draw the path discrete, i.e. make hard steps between points
+ *
+ * @var bool
+ */
+ protected $discrete = false;
+
+ /**
+ * Create the path using the given points
+ *
+ * @param array $points Either a single [x, y] point or an array of x, y points
+ */
+ public function __construct(array $points)
+ {
+ $this->append($points);
+ }
+
+ /**
+ * Append a single point or an array of points to this path
+ *
+ * @param array $points Either a single [x, y] point or an array of x, y points
+ *
+ * @return $this Fluid interface
+ */
+ public function append(array $points)
+ {
+ if (count($points) === 0) {
+ return $this;
+ }
+ if (!is_array($points[0])) {
+ $points = array($points);
+ }
+ $this->points = array_merge($this->points, $points);
+ return $this;
+ }
+
+ /**
+ * Prepend a single point or an array of points to this path
+ *
+ * @param array $points Either a single [x, y] point or an array of x, y points
+ *
+ * @return $this Fluid interface
+ */
+ public function prepend(array $points)
+ {
+ if (count($points) === 0) {
+ return $this;
+ }
+ if (!is_array($points[0])) {
+ $points = array($points);
+ }
+ $this->points = array_merge($points, $this->points);
+ return $this;
+ }
+
+ /**
+ * Set this path to be discrete
+ *
+ * @param boolean $bool True to draw discrete or false to draw straight lines between points
+ *
+ * @return $this Fluid interface
+ */
+ public function setDiscrete($bool)
+ {
+ $this->discrete = $bool;
+ return $this;
+ }
+
+ /**
+ * Mark this path as containing absolute coordinates
+ *
+ * @return $this Fluid interface
+ */
+ public function toAbsolute()
+ {
+ $this->isAbsolute = true;
+ return $this;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $doc = $ctx->getDocument();
+ $group = $doc->createElement('g');
+
+ $pathDescription = '';
+ $tpl = self::TPL_MOVE;
+ $lastPoint = null;
+ foreach ($this->points as $point) {
+ if (!$this->isAbsolute) {
+ $point = $ctx->toAbsolute($point[0], $point[1]);
+ }
+ $point[0] = Format::formatSVGNumber($point[0]);
+ $point[1] = Format::formatSVGNumber($point[1]);
+ if ($lastPoint && $this->discrete) {
+ $pathDescription .= sprintf($tpl, $point[0], $lastPoint[1]);
+ }
+ $pathDescription .= vsprintf($tpl, $point);
+ $lastPoint = $point;
+ $tpl = self::TPL_STRAIGHT;
+ }
+
+ $path = $doc->createElement('path');
+
+ $id = $this->id ?? uniqid('path-');
+ $path->setAttribute('id', $id);
+ $this->setId($id);
+
+ $path->setAttribute('d', $pathDescription);
+
+ $this->applyAttributes($path);
+ $style = new DOMDocument();
+ $style->loadHTML($this->getStyle());
+
+ $path->appendChild(
+ $path->ownerDocument->importNode(
+ $style->getElementsByTagName('style')->item(0),
+ true
+ )
+ );
+
+ $group->appendChild($path);
+ return $group;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/PieSlice.php b/library/Icinga/Chart/Primitive/PieSlice.php
new file mode 100644
index 0000000..f898435
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/PieSlice.php
@@ -0,0 +1,307 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMDocument;
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Format;
+
+/**
+ * Component for drawing a pie slice
+ */
+class PieSlice extends Animatable implements Drawable
+{
+ /**
+ * The radius of this pieslice relative to the canvas
+ *
+ * @var int
+ */
+ private $radius = 50;
+
+ /**
+ * The start radian of the pie slice
+ *
+ * @var float
+ */
+ private $startRadian = 0;
+
+ /**
+ * The end radian of the pie slice
+ *
+ * @var float
+ */
+ private $endRadian = 0;
+
+ /**
+ * The x position of the pie slice's center
+ *
+ * @var int
+ */
+ private $x;
+
+ /**
+ * The y position of the pie slice's center
+ *
+ * @var int
+ */
+ private $y;
+
+ /**
+ * The caption of the pie slice, empty string means no caption
+ *
+ * @var string
+ */
+ private $caption = "";
+
+ /**
+ * The offset of the caption, shifting the indicator from the center of the pie slice
+ *
+ * This is required for nested pie slices.
+ *
+ * @var int
+ */
+ private $captionOffset = 0;
+
+ /**
+ * The minimum radius the label must respect
+ *
+ * @var int
+ */
+ private $outerCaptionBound = 0;
+
+ /**
+ * An optional group element to add labels to when rendering
+ *
+ * @var DOMElement
+ */
+ private $labelGroup;
+
+ /**
+ * Create a pie slice
+ *
+ * @param int $radius The radius of the slice
+ * @param int $percent The percentage the slice represents
+ * @param int $percentStart The percentage where this slice starts
+ */
+ public function __construct($radius, $percent, $percentStart = 0)
+ {
+ $this->x = $this->y = $this->radius = $radius;
+
+ $this->startRadian = M_PI * $percentStart/50;
+ $this->endRadian = M_PI * ($percent + $percentStart)/50;
+ }
+
+ /**
+ * Create the path for the pie slice
+ *
+ * @param int $x The x position of the pie slice
+ * @param int $y The y position of the pie slice
+ * @param int $r The absolute radius of the pie slice
+ *
+ * @return string A SVG path string
+ */
+ private function getPieSlicePath($x, $y, $r)
+ {
+ // The coordinate system is mirrored on the Y axis, so we have to flip cos and sin
+ $xStart = $x + ($r * sin($this->startRadian));
+ $yStart = $y - ($r * cos($this->startRadian));
+
+ if ($this->endRadian - $this->startRadian == 2*M_PI) {
+ // To draw a full circle, adjust arc endpoint by a small (unvisible) value
+ $this->endRadian -= 0.001;
+ $pathString = 'M ' . Format::formatSVGNumber($xStart) . ' ' . Format::formatSVGNumber($yStart);
+ } else {
+ // Start at the center of the pieslice
+ $pathString = 'M ' . $x . ' ' . $y;
+ // Draw a straight line to the upper part of the arc
+ $pathString .= ' L ' . Format::formatSVGNumber($xStart) . ' ' . Format::formatSVGNumber($yStart);
+ }
+
+ // Instead of directly connecting the upper part of the arc (leaving a triangle), draw a bow with the radius
+ $pathString .= ' A ' . Format::formatSVGNumber($r) . ' ' . Format::formatSVGNumber($r);
+ // These are the flags for the bow, see the SVG path documentation for details
+ // http://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands
+ $pathString .= ' 0 ' . (($this->endRadian - $this->startRadian > M_PI) ? '1' : '0 ') . ' 1';
+
+ // xEnd and yEnd are the lower point of the arc
+ $xEnd = $x + ($r * sin($this->endRadian));
+ $yEnd = $y - ($r * cos($this->endRadian));
+ $pathString .= ' ' . Format::formatSVGNumber($xEnd) . ' ' . Format::formatSVGNumber($yEnd);
+
+ return $pathString;
+ }
+
+ /**
+ * Draw the label handler and the text for this pie slice
+ *
+ * @param RenderContext $ctx The rendering context to use for coordinate translation
+ * @param int $r The radius of the pie in absolute coordinates
+ *
+ * @return DOMElement The group DOMElement containing the handle and label
+ */
+ private function drawDescriptionLabel(RenderContext $ctx, $r)
+ {
+ $group = $ctx->getDocument()->createElement('g');
+ $rOuter = ($ctx->xToAbsolute($this->outerCaptionBound) + $ctx->yToAbsolute($this->outerCaptionBound)) / 2;
+ $addOffset = $rOuter - $r ;
+ if ($addOffset < 0) {
+ $addOffset = 0;
+ }
+ list($x, $y) = $ctx->toAbsolute($this->x, $this->y);
+ $midRadius = $this->startRadian + ($this->endRadian - $this->startRadian) / 2;
+ list($offsetX, $offsetY) = $ctx->toAbsolute($this->captionOffset, $this->captionOffset);
+
+ $midX = $x + intval(($offsetX + $r)/2 * sin($midRadius));
+ $midY = $y - intval(($offsetY + $r)/2 * cos($midRadius));
+
+ // Draw the handle
+ $path = new Path(array($midX, $midY));
+
+ $midX += ($addOffset + $r/3) * ($midRadius > M_PI ? -1 : 1);
+ $path->append(array($midX, $midY))->toAbsolute();
+
+ $midX += intval($r/2 * sin(M_PI/9)) * ($midRadius > M_PI ? -1 : 1);
+ $midY -= intval($r/2 * cos(M_PI/3)) * ($midRadius < M_PI*1.4 && $midRadius > M_PI/3 ? -1 : 1);
+
+ if ($ctx->yToRelative($midY) > 100) {
+ $midY = $ctx->yToAbsolute(100);
+ } elseif ($ctx->yToRelative($midY) < 0) {
+ $midY = $ctx->yToAbsolute($ctx->yToRelative(100+$midY));
+ }
+
+ $path->append(array($midX , $midY));
+ $rel = $ctx->toRelative($midX, $midY);
+
+ // Draw the text box
+ $text = new Text($rel[0]+1.5, $rel[1], $this->caption);
+ $text->setFontSize('5em');
+ $text->setAlignment(($midRadius > M_PI ? Text::ALIGN_END : Text::ALIGN_START));
+
+ $group->appendChild($path->toSvg($ctx));
+ $group->appendChild($text->toSvg($ctx));
+
+ return $group;
+ }
+
+ /**
+ * Set the x position of the pie slice
+ *
+ * @param int $x The new x position
+ *
+ * @return $this Fluid interface
+ */
+ public function setX($x)
+ {
+ $this->x = $x;
+ return $this;
+ }
+
+ /**
+ * Set the y position of the pie slice
+ *
+ * @param int $y The new y position
+ *
+ * @return $this Fluid interface
+ */
+ public function setY($y)
+ {
+ $this->y = $y;
+ return $this;
+ }
+
+ /**
+ * Set a root element to be used for drawing labels
+ *
+ * @param DOMElement $group The label group
+ *
+ * @return $this Fluid interface
+ */
+ public function setLabelGroup(DOMElement $group)
+ {
+ $this->labelGroup = $group;
+ return $this;
+ }
+
+ /**
+ * Set the caption for this label
+ *
+ * @param string $caption The caption for this element
+ *
+ * @return $this Fluid interface
+ */
+ public function setCaption($caption)
+ {
+ $this->caption = $caption;
+ return $this;
+ }
+
+ /**
+ * Set the internal offset of the caption handle
+ *
+ * @param int $offset The offset for the caption handle
+ *
+ * @return $this Fluid interface
+ */
+ public function setCaptionOffset($offset)
+ {
+ $this->captionOffset = $offset;
+ return $this;
+ }
+
+ /**
+ * Set the minimum radius to be used for drawing labels
+ *
+ * @param int $bound The offset for the caption text
+ *
+ * @return $this Fluid interface
+ */
+ public function setOuterCaptionBound($bound)
+ {
+ $this->outerCaptionBound = $bound;
+ return $this;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $doc = $ctx->getDocument();
+ $group = $doc->createElement('g');
+ $r = ($ctx->xToAbsolute($this->radius) + $ctx->yToAbsolute($this->radius)) / 2;
+ list($x, $y) = $ctx->toAbsolute($this->x, $this->y);
+
+ $slicePath = $doc->createElement('path');
+
+ $slicePath->setAttribute('d', $this->getPieSlicePath($x, $y, $r));
+ $slicePath->setAttribute('data-icinga-graph-type', 'pieslice');
+
+ $id = $this->id ?? uniqid('slice-');
+ $slicePath->setAttribute('id', $id);
+ $this->setId($id);
+
+ $style = new DOMDocument();
+ $style->loadHTML($this->getStyle());
+
+ $slicePath->appendChild(
+ $slicePath->ownerDocument->importNode(
+ $style->getElementsByTagName('style')->item(0),
+ true
+ )
+ );
+
+ $this->applyAttributes($slicePath);
+ $group->appendChild($slicePath);
+ if ($this->caption != "") {
+ $lblGroup = ($this->labelGroup ? $this->labelGroup : $group);
+ $lblGroup->appendChild($this->drawDescriptionLabel($ctx, $r));
+ }
+ return $group;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/RawElement.php b/library/Icinga/Chart/Primitive/RawElement.php
new file mode 100644
index 0000000..721b6e0
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/RawElement.php
@@ -0,0 +1,43 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMElement;
+use Icinga\Chart\Render\RenderContext;
+
+/**
+ * Wrapper for raw elements to be added as Drawable's
+ */
+class RawElement implements Drawable
+{
+
+ /**
+ * The DOMElement wrapped by this Drawable
+ *
+ * @var DOMElement
+ */
+ private $domEl;
+
+ /**
+ * Create this RawElement
+ *
+ * @param DOMElement $el The element to wrap here
+ */
+ public function __construct(DOMElement $el)
+ {
+ $this->domEl = $el;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ return $this->domEl;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Rect.php b/library/Icinga/Chart/Primitive/Rect.php
new file mode 100644
index 0000000..0c0835f
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Rect.php
@@ -0,0 +1,119 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Primitive;
+
+use DOMElement;
+use DOMDocument;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Format;
+
+/**
+ * Drawable representing the SVG rect element
+ */
+class Rect extends Animatable implements Drawable
+{
+ /**
+ * The x position
+ *
+ * @var int
+ */
+ private $x;
+
+ /**
+ * The y position
+ *
+ * @var int
+ */
+ private $y;
+
+ /**
+ * The width of this rect
+ *
+ * @var int
+ */
+ private $width;
+
+ /**
+ * The height of this rect
+ *
+ * @var int
+ */
+ private $height;
+
+ /**
+ * Whether to keep the ratio
+ *
+ * @var bool
+ */
+ private $keepRatio = false;
+
+ /**
+ * Create this rect
+ *
+ * @param int $x The x position of the rect
+ * @param int $y The y position of the rectangle
+ * @param int $width The width of the rectangle
+ * @param int $height The height of the rectangle
+ */
+ public function __construct($x, $y, $width, $height)
+ {
+ $this->x = $x;
+ $this->y = $y;
+ $this->width = $width;
+ $this->height = $height;
+ }
+
+ /**
+ * Call to let the rectangle keep the ratio
+ */
+ public function keepRatio()
+ {
+ $this->keepRatio = true;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $doc = $ctx->getDocument();
+ $rect = $doc->createElement('rect');
+
+ list($x, $y) = $ctx->toAbsolute($this->x, $this->y);
+ if ($this->keepRatio) {
+ $ctx->keepRatio();
+ }
+ list($width, $height) = $ctx->toAbsolute($this->width, $this->height);
+ if ($this->keepRatio) {
+ $ctx->ignoreRatio();
+ }
+ $rect->setAttribute('x', Format::formatSVGNumber($x));
+ $rect->setAttribute('y', Format::formatSVGNumber($y));
+ $rect->setAttribute('width', Format::formatSVGNumber($width));
+ $rect->setAttribute('height', Format::formatSVGNumber($height));
+
+ $id = $this->id ?? uniqid('rect-');
+ $rect->setAttribute('id', $id);
+ $this->setId($id);
+
+ $this->applyAttributes($rect);
+ $this->appendAnimation($rect, $ctx);
+
+ $style = new DOMDocument();
+ $style->loadHTML($this->getStyle());
+
+ $rect->appendChild(
+ $rect->ownerDocument->importNode(
+ $style->getElementsByTagName('style')->item(0),
+ true
+ )
+ );
+
+ return $rect;
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Styleable.php b/library/Icinga/Chart/Primitive/Styleable.php
new file mode 100644
index 0000000..15025bf
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Styleable.php
@@ -0,0 +1,161 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Primitive;
+
+use DOMElement;
+use Icinga\Util\Csp;
+use ipl\Web\Style;
+
+/**
+ * Base class for stylable drawables
+ */
+class Styleable
+{
+
+ /**
+ * The stroke width to use
+ *
+ * @var int|float
+ */
+ public $strokeWidth = 0;
+
+ /**
+ * The stroke color to use
+ *
+ * @var string
+ */
+ public $strokeColor = '#000';
+
+ /**
+ * The fill color to use
+ *
+ * @var string
+ */
+ public $fill = 'none';
+
+ /**
+ * Additional styles to be appended to the style attribute
+ *
+ * @var array<string, string>
+ */
+ public $additionalStyle = [];
+
+ /**
+ * The id of this element
+ *
+ * @var ?string
+ */
+ public $id = null;
+
+ /**
+ * Additional attributes to be set
+ *
+ * @var array
+ */
+ public $attributes = array();
+
+ /**
+ * Set the stroke width for this drawable
+ *
+ * @param int|float $width The stroke with unit
+ *
+ * @return $this Fluid interface
+ */
+ public function setStrokeWidth($width)
+ {
+ $this->strokeWidth = $width;
+ return $this;
+ }
+
+ /**
+ * Set the color for the stroke or none for no stroke
+ *
+ * @param string $color The color to set for the stroke
+ *
+ * @return $this Fluid interface
+ */
+ public function setStrokeColor($color)
+ {
+ $this->strokeColor = $color ? $color : 'none';
+ return $this;
+ }
+
+ /**
+ * Set additional styles for this drawable
+ *
+ * @param array<string, string> $styles The styles to set additionally
+ *
+ * @return $this Fluid interface
+ */
+ public function setAdditionalStyle($styles)
+ {
+ $this->additionalStyle = $styles;
+ return $this;
+ }
+
+ /**
+ * Set the fill for this styleable
+ *
+ * @param string $color The color to use for filling or null to use no fill
+ *
+ * @return $this Fluid interface
+ */
+ public function setFill($color = null)
+ {
+ $this->fill = $color ? $color : 'none';
+ return $this;
+ }
+
+ /**
+ * Set the id for this element
+ *
+ * @param string $id The id to set for this element
+ *
+ * @return $this Fluid interface
+ */
+ public function setId($id)
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ /**
+ * Return the ruleset used for styling the DOMNode
+ *
+ * @return Style A ruleset containing styles
+ */
+ public function getStyle()
+ {
+ $styles = $this->additionalStyle;
+ $styles['fill'] = $this->fill;
+ $styles['stroke'] = $this->strokeColor;
+ $styles['stroke-width'] = (string) $this->strokeWidth;
+
+ return (new Style())
+ ->setNonce(Csp::getStyleNonce())
+ ->add("#$this->id", $styles);
+ }
+
+ /**
+ * Add an additional attribute to this element
+ */
+ public function setAttribute($key, $value)
+ {
+ $this->attributes[$key] = $value;
+ }
+
+ /**
+ * Apply attribute to a DOMElement
+ *
+ * @param DOMElement $el Element to apply attributes
+ */
+ protected function applyAttributes(DOMElement $el)
+ {
+ foreach ($this->attributes as $name => $value) {
+ $el->setAttribute($name, $value);
+ }
+ }
+}
diff --git a/library/Icinga/Chart/Primitive/Text.php b/library/Icinga/Chart/Primitive/Text.php
new file mode 100644
index 0000000..f6bf365
--- /dev/null
+++ b/library/Icinga/Chart/Primitive/Text.php
@@ -0,0 +1,184 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Primitive;
+
+use DOMDocument;
+use DOMElement;
+use DOMText;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Format;
+use ipl\Html\HtmlDocument;
+
+/**
+ * Wrapper for the SVG text element
+ */
+class Text extends Styleable implements Drawable
+{
+ /**
+ * Align the text to end at the x and y position
+ */
+ const ALIGN_END = 'end';
+
+ /**
+ * Align the text to start at the x and y position
+ */
+ const ALIGN_START = 'start';
+
+ /**
+ * Align the text to be centered at the x and y position
+ */
+ const ALIGN_MIDDLE = 'middle';
+
+ /**
+ * The x position of the Text
+ *
+ * @var int
+ */
+ private $x;
+
+ /**
+ * The y position of the Text
+ *
+ * @var int
+ */
+ private $y;
+
+ /**
+ * The text content
+ *
+ * @var string
+ */
+ private $text;
+
+ /**
+ * The size of the font
+ *
+ * @var string
+ */
+ private $fontSize = '1.5em';
+
+ /**
+ * The weight of the font
+ *
+ * @var string
+ */
+ private $fontWeight = 'normal';
+
+ /**
+ * The default fill color
+ *
+ * @var string
+ */
+ public $fill = '#000';
+
+ /**
+ * The alignment of the text
+ *
+ * @var string
+ */
+ private $alignment = self::ALIGN_START;
+
+ /**
+ * Set the font-stretch property of the text
+ */
+ private $fontStretch = 'semi-condensed';
+
+ /**
+ * Construct a new text drawable
+ *
+ * @param int $x The x position of the text
+ * @param int $y The y position of the text
+ * @param string $text The text this component should contain
+ * @param string $fontSize The font size of the text
+ */
+ public function __construct($x, $y, $text, $fontSize = '1.5em')
+ {
+ $this->x = $x;
+ $this->y = $y;
+ $this->text = $text;
+ $this->fontSize = $fontSize;
+
+ $this->setAdditionalStyle([
+ 'font-size' => $this->fontSize,
+ 'font-family' => 'Ubuntu, Calibri, Trebuchet MS, Helvetica, Verdana, sans-serif',
+ 'font-weight' => $this->fontWeight,
+ 'font-stretch' => $this->fontStretch,
+ 'font-style' => 'normal',
+ 'text-anchor' => $this->alignment
+ ]);
+ }
+
+ /**
+ * Set the font size of the svg text element
+ *
+ * @param string $size The font size including a unit
+ *
+ * @return $this Fluid interface
+ */
+ public function setFontSize($size)
+ {
+ $this->fontSize = $size;
+ return $this;
+ }
+
+ /**
+ * Set the text alignment with one of the ALIGN_* constants
+ *
+ * @param String $align Value how to align
+ *
+ * @return $this Fluid interface
+ */
+ public function setAlignment($align)
+ {
+ $this->alignment = $align;
+ return $this;
+ }
+
+ /**
+ * Set the weight of the current font
+ *
+ * @param string $weight The weight of the string
+ *
+ * @return $this Fluid interface
+ */
+ public function setFontWeight($weight)
+ {
+ $this->fontWeight = $weight;
+ return $this;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ list($x, $y) = $ctx->toAbsolute($this->x, $this->y);
+ $text = $ctx->getDocument()->createElement('text');
+ $text->setAttribute('x', Format::formatSVGNumber($x - 15));
+
+ $id = $this->id ?? uniqid('text-');
+ $text->setAttribute('id', $id);
+ $this->setId($id);
+
+ $text->setAttribute('y', Format::formatSVGNumber($y));
+ $text->appendChild(new DOMText($this->text));
+
+ $style = new DOMDocument();
+ $style->loadHTML($this->getStyle());
+
+ $text->appendChild(
+ $text->ownerDocument->importNode(
+ $style->getElementsByTagName('style')->item(0),
+ true
+ )
+ );
+
+ return $text;
+ }
+}
diff --git a/library/Icinga/Chart/Render/LayoutBox.php b/library/Icinga/Chart/Render/LayoutBox.php
new file mode 100644
index 0000000..fa49461
--- /dev/null
+++ b/library/Icinga/Chart/Render/LayoutBox.php
@@ -0,0 +1,200 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Render;
+
+use Icinga\Chart\Format;
+
+/**
+ * Layout class encapsulating size, padding and margin information
+ */
+class LayoutBox
+{
+ /**
+ * Padding index for top padding
+ */
+ const PADDING_TOP = 0;
+
+ /**
+ * Padding index for right padding
+ */
+ const PADDING_RIGHT = 1;
+
+ /**
+ * Padding index for bottom padding
+ */
+ const PADDING_BOTTOM = 2;
+
+ /**
+ * Padding index for left padding
+ */
+ const PADDING_LEFT = 3;
+
+ /**
+ * The height of this layout element
+ *
+ * @var int
+ */
+ private $height;
+
+ /**
+ * The width of this layout element
+ *
+ * @var int
+ */
+ private $width;
+
+ /**
+ * The x position of this layout
+ *
+ * @var int
+ */
+ private $x;
+
+ /**
+ * The y position of this layout
+ *
+ * @var int
+ */
+ private $y;
+
+ /**
+ * The padding of this layout
+ *
+ * @var array
+ */
+ private $padding = array(0, 0, 0, 0);
+
+ /**
+ * Create this layout box
+ *
+ * Note that x, y, width and height are relative: x with 0 means leftmost, x with 100 means rightmost
+ *
+ * @param int $x The relative x coordinate
+ * @param int $y The relative y coordinate
+ * @param int $width The optional, relative width
+ * @param int $height The optional, relative height
+ */
+ public function __construct($x, $y, $width = null, $height = null)
+ {
+ $this->height = $height ? $height : 100;
+ $this->width = $width ? $width : 100;
+ $this->x = $x;
+ $this->y = $y;
+ }
+
+ /**
+ * Set a padding to all four sides uniformly
+ *
+ * @param int $padding The padding to set for all four sides
+ */
+ public function setUniformPadding($padding)
+ {
+ $this->padding = array($padding, $padding, $padding, $padding);
+ }
+
+ /**
+ * Set the padding for this LayoutBox
+ *
+ * @param int $top The top side padding
+ * @param int $right The right side padding
+ * @param int $bottom The bottom side padding
+ * @param int $left The left side padding
+ */
+ public function setPadding($top, $right, $bottom, $left)
+ {
+ $this->padding = array($top, $right, $bottom, $left);
+ }
+
+ /**
+ * Return a string containing the SVG transform attribute values for the padding
+ *
+ * @param RenderContext $ctx The context to determine the translation coordinates
+ *
+ * @return string The transformation string
+ */
+ public function getInnerTransform(RenderContext $ctx)
+ {
+ list($translateX, $translateY) = $ctx->toAbsolute(
+ $this->padding[self::PADDING_LEFT] + $this->getX(),
+ $this->padding[self::PADDING_TOP] + $this->getY()
+ );
+ list($scaleX, $scaleY) = $ctx->paddingToScaleFactor($this->padding);
+
+ $scaleX *= $this->getWidth()/100;
+ $scaleY *= $this->getHeight()/100;
+ return sprintf(
+ 'translate(%s, %s) scale(%s, %s)',
+ Format::formatSVGNumber($translateX),
+ Format::formatSVGNumber($translateY),
+ Format::formatSVGNumber($scaleX),
+ Format::formatSVGNumber($scaleY)
+ );
+ }
+
+ /**
+ * String representation for this Layout, for debug purposes
+ *
+ * @return string A string containing the bounds of this LayoutBox
+ */
+ public function __toString()
+ {
+ return sprintf(
+ 'Rectangle: x: %s y: %s, height: %s, width: %s',
+ $this->x,
+ $this->y,
+ $this->height,
+ $this->width
+ );
+ }
+
+ /**
+ * Return a four element array with the padding
+ *
+ * @return array The padding of this LayoutBox
+ */
+ public function getPadding()
+ {
+ return $this->padding;
+ }
+
+ /**
+ * Return the height of this LayoutBox
+ *
+ * @return int The height of this box
+ */
+ public function getHeight()
+ {
+ return $this->height;
+ }
+
+ /**
+ * Return the width of this LayoutBox
+ *
+ * @return int The width of this box
+ */
+ public function getWidth()
+ {
+ return $this->width;
+ }
+
+ /**
+ * Return the x position of this LayoutBox
+ *
+ * @return int The x position of this box
+ */
+ public function getX()
+ {
+ return $this->x;
+ }
+
+ /**
+ * Return the y position of this LayoutBox
+ *
+ * @return int The y position of this box
+ */
+ public function getY()
+ {
+ return $this->y;
+ }
+}
diff --git a/library/Icinga/Chart/Render/RenderContext.php b/library/Icinga/Chart/Render/RenderContext.php
new file mode 100644
index 0000000..457fbf3
--- /dev/null
+++ b/library/Icinga/Chart/Render/RenderContext.php
@@ -0,0 +1,225 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Render;
+
+use DOMDocument;
+
+/**
+ * Context for rendering, handles ratio based coordinate calculations.
+ *
+ * The most important functions when rendering are the toAbsolute and roRelative
+ * values, taking world coordinates and translating them into local coordinates.
+ */
+class RenderContext
+{
+
+ /**
+ * The base size of the viewport, i.e. how many units are available on a 1:1 ratio
+ *
+ * @var array
+ */
+ private $viewBoxSize = array(1000, 1000);
+
+
+ /**
+ * The DOMDocument for modifying the elements
+ *
+ * @var DOMDocument
+ */
+ private $document;
+
+ /**
+ * If true no ratio correction will be made
+ *
+ * @var bool
+ */
+ private $respectRatio = false;
+
+ /**
+ * The ratio on the x side. A x ration of 2 means that the width of the SVG is divided in 2000
+ * units (see $viewBox)
+ *
+ * @var int
+ */
+ private $xratio = 1;
+
+ /**
+ * The ratio on the y side. A y ration of 2 means that the height of the SVG is divided in 2000
+ * units (see $viewBox)
+ *
+ * @var int
+ */
+ private $yratio = 1;
+
+ /**
+ * Creates a new context for the given DOM Document
+ *
+ * @param DOMDocument $document The DOM document represented by this context
+ * @param int $width The width (may be approximate) of the document
+ * (only required for ratio calculation)
+ * @param int $height The height (may be approximate) of the document
+ * (only required for ratio calculation)
+ */
+ public function __construct(DOMDocument $document, $width, $height)
+ {
+ $this->document = $document;
+ if ($width > $height) {
+ $this->xratio = $width / $height;
+ } elseif ($height > $width) {
+ $this->yratio = $height / $width;
+ }
+ }
+
+ /**
+ * Return the document represented by this Rendering context
+ *
+ * @return DOMDocument The DOMDocument for creating files
+ */
+ public function getDocument()
+ {
+ return $this->document;
+ }
+
+ /**
+ * Let successive toAbsolute operations ignore ratio correction
+ *
+ * This can be called to avoid distortion on certain elements like rectangles.
+ */
+ public function keepRatio()
+ {
+ $this->respectRatio = true;
+ }
+
+ /**
+ * Let successive toAbsolute operations perform ratio correction
+ *
+ * This will cause distortion on certain elements like rectangles.
+ */
+ public function ignoreRatio()
+ {
+ $this->respectRatio = false;
+ }
+
+ /**
+ * Return how many unit s are available in the Y axis
+ *
+ * @return int The number of units available on the y axis
+ */
+ public function getNrOfUnitsY()
+ {
+ return intval($this->viewBoxSize[1] * $this->yratio);
+ }
+
+ /**
+ * Return how many unit s are available in the X axis
+ *
+ * @return int The number of units available on the x axis
+ */
+ public function getNrOfUnitsX()
+ {
+ return intval($this->viewBoxSize[0] * $this->xratio);
+ }
+
+ /**
+ * Transforms the x,y coordinate from relative coordinates to absolute world coordinates
+ *
+ * (50, 50) would be a point in the middle of the document and map to 500, 1000 on a
+ * 1000 x 1000 viewbox with a 1:2 ratio.
+ *
+ * @param int $x The relative x coordinate
+ * @param int $y The relative y coordinate
+ *
+ * @return array An x,y tuple containing absolute coordinates
+ * @see RenderContext::toRelative
+ */
+ public function toAbsolute($x, $y)
+ {
+ return array($this->xToAbsolute($x), $this->yToAbsolute($y));
+ }
+
+ /**
+ * Transforms the x,y coordinate from absolute coordinates to relative world coordinates
+ *
+ * This is the inverse function of toAbsolute
+ *
+ * @param int $x The absolute x coordinate
+ * @param int $y The absolute y coordinate
+ *
+ * @return array An x,y tupel containing absolute coordinates
+ * @see RenderContext::toAbsolute
+ */
+ public function toRelative($x, $y)
+ {
+ return array($this->xToRelative($x), $this->yToRelative($y));
+ }
+
+ /**
+ * Calculates the scale transformation required to apply the padding on an Canvas
+ *
+ * @param array $padding A 4 element array containing top, right, bottom and left padding
+ *
+ * @return array An array containing the x and y scale
+ */
+ public function paddingToScaleFactor(array $padding)
+ {
+ list($horizontalPadding, $verticalPadding) = $this->toAbsolute(
+ $padding[LayoutBox::PADDING_RIGHT] + $padding[LayoutBox::PADDING_LEFT],
+ $padding[LayoutBox::PADDING_TOP] + $padding[LayoutBox::PADDING_BOTTOM]
+ );
+
+ return array(
+ ($this->getNrOfUnitsX() - $horizontalPadding) / $this->getNrOfUnitsX(),
+ ($this->getNrOfUnitsY() - $verticalPadding) / $this->getNrOfUnitsY()
+ );
+ }
+
+ /**
+ * Transform a relative x coordinate to an absolute one
+ *
+ * @param int $x A relative x coordinate
+ *
+ * @return int An absolute x coordinate
+ **/
+ public function xToAbsolute($x)
+ {
+ return $this->getNrOfUnitsX() / 100 * $x / ($this->respectRatio ? $this->xratio : 1);
+ }
+
+ /**
+ * Transform a relative y coordinate to an absolute one
+ *
+ * @param int $y A relative y coordinate
+ *
+ * @return int An absolute y coordinate
+ */
+ public function yToAbsolute($y)
+ {
+ return $this->getNrOfUnitsY() / 100 * $y / ($this->respectRatio ? $this->yratio : 1);
+ }
+
+ /**
+ * Transform a absolute x coordinate to an relative one
+ *
+ * @param int $x An absolute x coordinate
+ *
+ * @return int A relative x coordinate
+ */
+ public function xToRelative($x)
+ {
+ return $x / $this->getNrOfUnitsX() * 100 * ($this->respectRatio ? $this->xratio : 1);
+ }
+
+ /**
+ * Transform a absolute y coordinate to an relative one
+ *
+ * @param int $y An absolute x coordinate
+ *
+ * @return int A relative x coordinate
+ */
+ public function yToRelative($y)
+ {
+ return $y / $this->getNrOfUnitsY() * 100 * ($this->respectRatio ? $this->yratio : 1);
+ }
+}
diff --git a/library/Icinga/Chart/Render/Rotator.php b/library/Icinga/Chart/Render/Rotator.php
new file mode 100644
index 0000000..3e7071c
--- /dev/null
+++ b/library/Icinga/Chart/Render/Rotator.php
@@ -0,0 +1,80 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Render;
+
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Primitive\Drawable;
+use DOMElement;
+
+/**
+ * Class Rotator
+ * @package Icinga\Chart\Render
+ */
+class Rotator implements Drawable
+{
+ /**
+ * The drawable element to rotate
+ *
+ * @var Drawable
+ */
+ private $element;
+
+ /**
+ * @var int
+ */
+ private $degrees;
+
+ /**
+ * Wrap an element into a new instance of Rotator
+ *
+ * @param Drawable $element The element to rotate
+ * @param int $degrees The amount of degrees
+ */
+ public function __construct(Drawable $element, $degrees)
+ {
+ $this->element = $element;
+ $this->degrees = $degrees;
+ }
+
+ /**
+ * Rotate the given element.
+ *
+ * @param RenderContext $ctx The rendering context
+ * @param DOMElement $el The element to rotate
+ * @param $degrees The amount of degrees
+ *
+ * @return DOMElement The rotated DOMElement
+ */
+ private function rotate(RenderContext $ctx, DOMElement $el, $degrees)
+ {
+ // Create a box containing the rotated element relative to the original element position
+ $container = $ctx->getDocument()->createElement('g');
+ $x = $el->getAttribute('x');
+ $y = $el->getAttribute('y');
+ $container->setAttribute('transform', 'translate(' . $x . ',' . $y . ')');
+ $el->removeAttribute('x');
+ $el->removeAttribute('y');
+
+ // Put the element into a rotated group
+ //$rotate = $ctx->getDocument()->createElement('g');
+ $el->setAttribute('transform', 'rotate(' . $degrees . ')');
+ //$rotate->appendChild($el);
+
+ $container->appendChild($el);
+ return $container;
+ }
+
+ /**
+ * Create the SVG representation from this Drawable
+ *
+ * @param RenderContext $ctx The context to use for rendering
+ *
+ * @return DOMElement The SVG Element
+ */
+ public function toSvg(RenderContext $ctx)
+ {
+ $el = $this->element->toSvg($ctx);
+ return $this->rotate($ctx, $el, $this->degrees);
+ }
+}
diff --git a/library/Icinga/Chart/SVGRenderer.php b/library/Icinga/Chart/SVGRenderer.php
new file mode 100644
index 0000000..d3891f2
--- /dev/null
+++ b/library/Icinga/Chart/SVGRenderer.php
@@ -0,0 +1,331 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use DOMNode;
+use DOMElement;
+use DOMDocument;
+use DOMImplementation;
+use Icinga\Chart\Render\LayoutBox;
+use Icinga\Chart\Render\RenderContext;
+use Icinga\Chart\Primitive\Canvas;
+
+/**
+ * SVG Renderer component.
+ *
+ * Creates the basic DOM tree of the SVG to use
+ */
+class SVGRenderer
+{
+ const X_ASPECT_RATIO_MIN = 'xMin';
+
+ const X_ASPECT_RATIO_MID = 'xMid';
+
+ const X_ASPECT_RATIO_MAX = 'xMax';
+
+ const Y_ASPECT_RATIO_MIN = 'YMin';
+
+ const Y_ASPECT_RATIO_MID = 'YMid';
+
+ const Y_ASPECT_RATIO_MAX = 'YMax';
+
+ const ASPECT_RATIO_PAD = 'meet';
+
+ const ASPECT_RATIO_CUTOFF = 'slice';
+
+ /**
+ * The XML-document
+ *
+ * @var DOMDocument
+ */
+ private $document;
+
+ /**
+ * The SVG-element
+ *
+ * @var DOMNode
+ */
+ private $svg;
+
+ /**
+ * The description of this SVG, useful for screen readers
+ *
+ * @var string
+ */
+ private $ariaDescription;
+
+ /**
+ * The title of this SVG, useful for screen readers
+ *
+ * @var string
+ */
+ private $ariaTitle;
+
+ /**
+ * The aria role used by this svg element
+ *
+ * @var string
+ */
+ private $ariaRole = 'img';
+
+ /**
+ * The root layer for all elements
+ *
+ * @var Canvas
+ */
+ private $rootCanvas;
+
+ /**
+ * The width of this renderer
+ *
+ * @var int
+ */
+ private $width = 100;
+
+ /**
+ * The height of this renderer
+ *
+ * @var int
+ */
+ private $height = 100;
+
+ /**
+ * Whether the aspect ratio is preversed
+ *
+ * @var bool
+ */
+ private $preserveAspectRatio = false;
+
+ /**
+ * Horizontal alignment of SVG element
+ *
+ * @var string
+ */
+ private $xAspectRatio = self::X_ASPECT_RATIO_MID;
+
+ /**
+ * Vertical alignment of SVG element
+ *
+ * @var string
+ */
+ private $yAspectRatio = self::Y_ASPECT_RATIO_MID;
+
+ /**
+ * Define whether aspect differences should be handled using padding (default) or cutoff
+ *
+ * @var string
+ */
+ private $xFillMode = "meet";
+
+
+ /**
+ * Create the root document and the SVG root node
+ */
+ private function createRootDocument()
+ {
+ $implementation = new DOMImplementation();
+ $docType = $implementation->createDocumentType(
+ 'svg',
+ '-//W3C//DTD SVG 1.1//EN',
+ 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'
+ );
+
+ $this->document = $implementation->createDocument(null, '', $docType);
+ $this->svg = $this->createOuterBox();
+ $this->document->appendChild($this->svg);
+ }
+
+ /**
+ * Create the outer SVG box containing the root svg element and namespace and return it
+ *
+ * @return DOMElement The SVG root node
+ */
+ private function createOuterBox()
+ {
+ $ctx = $this->createRenderContext();
+ $svg = $this->document->createElement('svg');
+ $svg->setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+ $svg->setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
+ $svg->setAttribute('role', $this->ariaRole);
+ $svg->setAttribute('width', '100%');
+ $svg->setAttribute('height', '100%');
+ $svg->setAttribute(
+ 'viewBox',
+ sprintf(
+ '0 0 %s %s',
+ $ctx->getNrOfUnitsX(),
+ $ctx->getNrOfUnitsY()
+ )
+ );
+ if ($this->preserveAspectRatio) {
+ $svg->setAttribute(
+ 'preserveAspectRatio',
+ sprintf(
+ '%s%s %s',
+ $this->xAspectRatio,
+ $this->yAspectRatio,
+ $this->xFillMode
+ )
+ );
+ }
+ return $svg;
+ }
+
+ /**
+ * Add aria title and description
+ *
+ * Adds an aria title and desc element to the given SVG node, which are used to describe this SVG by accessibility
+ * tools such as screen readers.
+ *
+ * @param DOMNode $svg The SVG DOMNode to which the aria attributes should be attached
+ * @param $title The title text
+ * @param $description The description text
+ */
+ private function addAriaDescription(DOMNode $svg, $titleText, $descriptionText)
+ {
+ $doc = $svg->ownerDocument;
+
+ $titleId = $descId = '';
+ if (isset($this->ariaTitle)) {
+ $titleId = 'aria-title-' . $this->stripNonAlphanumeric($titleText);
+ $title = $doc->createElement('title');
+ $title->setAttribute('id', $titleId);
+
+ $title->appendChild($doc->createTextNode($titleText));
+ $svg->appendChild($title);
+ }
+
+ if (isset($this->ariaDescription)) {
+ $descId = 'aria-desc-' . $this->stripNonAlphanumeric($descriptionText);
+ $desc = $doc->createElement('desc');
+ $desc->setAttribute('id', $descId);
+
+ $desc->appendChild($doc->createTextNode($descriptionText));
+ $svg->appendChild($desc);
+ }
+
+ $svg->setAttribute('aria-labelledby', join(' ', array($titleId, $descId)));
+ }
+
+ /**
+ * Initialises the XML-document, SVG-element and this figure's root canvas
+ *
+ * @param int $width The width ratio
+ * @param int $height The height ratio
+ */
+ public function __construct($width, $height)
+ {
+ $this->width = $width;
+ $this->height = $height;
+ $this->rootCanvas = new Canvas('root', new LayoutBox(0, 0));
+ }
+
+ /**
+ * Render the SVG-document
+ *
+ * @return string The resulting XML structure
+ */
+ public function render()
+ {
+ $this->createRootDocument();
+ $ctx = $this->createRenderContext();
+ $this->addAriaDescription($this->svg, $this->ariaTitle, $this->ariaDescription);
+ $this->svg->appendChild($this->rootCanvas->toSvg($ctx));
+ $this->document->formatOutput = true;
+ $this->document->encoding = 'UTF-8';
+ return $this->document->saveXML();
+ }
+
+ /**
+ * Create a render context that will be used for rendering elements
+ *
+ * @return RenderContext The created RenderContext instance
+ */
+ public function createRenderContext()
+ {
+ return new RenderContext($this->document, $this->width, $this->height);
+ }
+
+ /**
+ * Return the root canvas of this rendered
+ *
+ * @return Canvas The canvas that will be the uppermost element in this figure
+ */
+ public function getCanvas()
+ {
+ return $this->rootCanvas;
+ }
+
+ /**
+ * Preserve the aspect ratio of the rendered object
+ *
+ * Do not deform the content of the SVG when the aspect ratio of the viewBox
+ * differs from the aspect ratio of the SVG element, but add padding or cutoff
+ * instead
+ *
+ * @param bool $preserve Whether the aspect ratio should be preserved
+ */
+ public function preserveAspectRatio($preserve = true)
+ {
+ $this->preserveAspectRatio = $preserve;
+ }
+
+ /**
+ * Change the horizontal alignment of the SVG element
+ *
+ * Change the horizontal alignment of the svg, when preserveAspectRatio is used and
+ * padding is present. Defaults to
+ */
+ public function setXAspectRatioAlignment($alignment)
+ {
+ $this->xAspectRatio = $alignment;
+ }
+
+ /**
+ * Change the vertical alignment of the SVG element
+ *
+ * Change the vertical alignment of the svg, when preserveAspectRatio is used and
+ * padding is present.
+ */
+ public function setYAspectRatioAlignment($alignment)
+ {
+ $this->yAspectRatio = $alignment;
+ }
+
+ /**
+ * Set the aria description, that is used as a title for this SVG in screen readers
+ *
+ * @param $text
+ */
+ public function setAriaTitle($text)
+ {
+ $this->ariaTitle = $text;
+ }
+
+ /**
+ * Set the aria description, that is used to describe this SVG in screen readers
+ *
+ * @param $text
+ */
+ public function setAriaDescription($text)
+ {
+ $this->ariaDescription = $text;
+ }
+
+ /**
+ * Set the aria role, that is used to describe the purpose of this SVG in screen readers
+ *
+ * @param $text
+ */
+ public function setAriaRole($text)
+ {
+ $this->ariaRole = $text;
+ }
+
+
+ private function stripNonAlphanumeric($str)
+ {
+ return preg_replace('/[^A-Za-z]+/', '', $str);
+ }
+}
diff --git a/library/Icinga/Chart/Unit/AxisUnit.php b/library/Icinga/Chart/Unit/AxisUnit.php
new file mode 100644
index 0000000..251787f
--- /dev/null
+++ b/library/Icinga/Chart/Unit/AxisUnit.php
@@ -0,0 +1,56 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Unit;
+
+use Iterator;
+
+/**
+ * Base class for Axis Units
+ *
+ * An AxisUnit takes a set of values and places them on a given range
+ *
+ * Concrete subclasses must implement the iterator interface, with
+ * getCurrent returning the axis relative position and getValue the label
+ * that will be displayed
+ */
+interface AxisUnit extends Iterator
+{
+ /**
+ * Add a dataset to this AxisUnit, required for dynamic min and max vlaues
+ *
+ * @param array $dataset The dataset that will be shown in the Axis
+ * @param int $id The idx in the dataset (0 for x, 1 for y)
+ */
+ public function addValues(array $dataset, $id = 0);
+
+ /**
+ * Transform the given absolute value in an axis relative value
+ *
+ * @param int $value The absolute, dataset dependent value
+ *
+ * @return int An axis relative value
+ */
+ public function transform($value);
+
+ /**
+ * Set the axis minimum value to a fixed value
+ *
+ * @param int $min The new minimum value
+ */
+ public function setMin($min);
+
+ /**
+ * Set the axis maximum value to a fixed value
+ *
+ * @param int $max The new maximum value
+ */
+ public function setMax($max);
+
+ /**
+ * Get the amount of ticks of this axis
+ *
+ * @return int
+ */
+ public function getTicks();
+}
diff --git a/library/Icinga/Chart/Unit/CalendarUnit.php b/library/Icinga/Chart/Unit/CalendarUnit.php
new file mode 100644
index 0000000..74680c7
--- /dev/null
+++ b/library/Icinga/Chart/Unit/CalendarUnit.php
@@ -0,0 +1,167 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Unit;
+
+use DateTime;
+
+/**
+ * Calendar Axis Unit that transforms timestamps into user-readable values
+ *
+ */
+class CalendarUnit extends LinearUnit
+{
+ /**
+ * Constant for a minute
+ */
+ const MINUTE = 60;
+
+ /**
+ * Constant for an hour
+ */
+ const HOUR = 3600;
+
+ /**
+ * Constant for a day
+ */
+ const DAY = 864000;
+
+ /**
+ * Constant for ~a month
+ * 30 Days, this is sufficient for our needs
+ */
+ const MONTH = 2592000; // x
+
+ /**
+ * An array containing all labels that will be displayed
+ *
+ * @var array
+ */
+ private $labels = array();
+
+ /**
+ * The date format to use
+ *
+ * @var string
+ */
+ private $dateFormat = 'd-m';
+
+ /**
+ * The time format to use
+ *
+ * @var string
+ */
+ private $timeFormat = 'g:i:s';
+
+ /**
+ * Create the labels for the given dataset
+ */
+ private function createLabels()
+ {
+ $this->labels = array();
+ $duration = $this->getMax() - $this->getMin();
+
+ if ($duration <= self::HOUR) {
+ $unit = self::MINUTE;
+ } elseif ($duration <= self::DAY) {
+ $unit = self::HOUR;
+ } elseif ($duration <= self::MONTH) {
+ $unit = self::DAY;
+ } else {
+ $unit = self::MONTH;
+ }
+ $this->calculateLabels($unit);
+ }
+
+ /**
+ * Calculate the labels for this dataset
+ *
+ * @param integer $unit The unit to use as the basis for calculation
+ */
+ private function calculateLabels($unit)
+ {
+ $fac = new DateTime();
+
+ $duration = $this->getMax() - $this->getMin();
+
+ // Calculate number of ticks, but not more than 30
+ $tickCount = ($duration/$unit * 10);
+ if ($tickCount > 30) {
+ $tickCount = 30;
+ }
+
+ $step = $duration / $tickCount;
+ $format = $this->timeFormat;
+ if ($unit === self::DAY) {
+ $format = $this->dateFormat;
+ } elseif ($unit === self::MONTH) {
+ $format = $this->dateFormat;
+ }
+
+ for ($i = 0; $i <= $duration; $i += $step) {
+ $this->labels[] = $fac->setTimestamp($this->getMin() + $i)->format($format);
+ }
+ }
+
+ /**
+ * Add a dataset to this CalendarUnit and update labels
+ *
+ * @param array $dataset The dataset to update
+ * @param int $idx The index to use for determining the data
+ *
+ * @return $this Fluid interface
+ */
+ public function addValues(array $dataset, $idx = 0)
+ {
+ parent::addValues($dataset, $idx);
+ $this->createLabels();
+ return $this;
+ }
+
+ /**
+ * Return the current axis relative position
+ *
+ * @return int The position of the next tick (between 0 and 100)
+ */
+ public function current(): int
+ {
+ return 100 * (key($this->labels) / count($this->labels));
+ }
+
+ /**
+ * Move to next tick
+ */
+ public function next(): void
+ {
+ next($this->labels);
+ }
+
+ /**
+ * Return the current tick caption
+ *
+ * @return string
+ */
+ public function key(): string
+ {
+ return current($this->labels);
+ }
+
+ /**
+ * Return true when the iterator is in a valid range
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ return current($this->labels) !== false;
+ }
+
+ /**
+ * Rewind the internal array
+ */
+ public function rewind(): void
+ {
+ reset($this->labels);
+ }
+}
diff --git a/library/Icinga/Chart/Unit/LinearUnit.php b/library/Icinga/Chart/Unit/LinearUnit.php
new file mode 100644
index 0000000..ea4792b
--- /dev/null
+++ b/library/Icinga/Chart/Unit/LinearUnit.php
@@ -0,0 +1,227 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Unit;
+
+/**
+ * Linear tick distribution over the axis
+ */
+class LinearUnit implements AxisUnit
+{
+ /**
+ * The minimum value to display
+ *
+ * @var int
+ */
+ protected $min;
+
+ /**
+ * The maximum value to display
+ *
+ * @var int
+ */
+ protected $max;
+
+ /**
+ * True when the minimum value is static and isn't affected by the dataset
+ *
+ * @var bool
+ */
+ protected $staticMin = false;
+
+ /**
+ * True when the maximum value is static and isn't affected by the dataset
+ *
+ * @var bool
+ */
+ protected $staticMax = false;
+
+ /**
+ * The number of ticks to use
+ *
+ * @var int
+ */
+ protected $nrOfTicks = 10;
+
+ /**
+ * The currently displayed tick
+ *
+ * @var int
+ */
+ protected $currentTick = 0;
+
+ /**
+ * The currently displayed value
+ * @var int
+ */
+ protected $currentValue = 0;
+
+ /**
+ * Create and initialize this AxisUnit
+ *
+ * @param int $nrOfTicks The number of ticks to use
+ */
+ public function __construct($nrOfTicks = 10)
+ {
+ $this->min = PHP_INT_MAX;
+ $this->max = ~PHP_INT_MAX;
+ $this->nrOfTicks = $nrOfTicks;
+ }
+
+ /**
+ * Add a dataset and calculate the minimum and maximum value for this AxisUnit
+ *
+ * @param array $dataset The dataset to add
+ * @param int $idx The idx (0 for x, 1 for y)
+ *
+ * @return $this Fluent interface
+ */
+ public function addValues(array $dataset, $idx = 0)
+ {
+ $datapoints = array();
+
+ foreach ($dataset['data'] as $points) {
+ $datapoints[] = $points[$idx];
+ }
+ if (empty($datapoints)) {
+ return $this;
+ }
+ sort($datapoints);
+ if (!$this->staticMax) {
+ $this->max = max($this->max, $datapoints[count($datapoints) - 1]);
+ }
+ if (!$this->staticMin) {
+ $this->min = min($this->min, $datapoints[0]);
+ }
+ $this->currentTick = 0;
+ $this->currentValue = $this->min;
+ if ($this->max === $this->min) {
+ $this->max = $this->min + 10;
+ }
+ $this->nrOfTicks = $this->max - $this->min;
+ return $this;
+ }
+
+ /**
+ * Transform the absolute value to an axis relative value
+ *
+ * @param int $value The absolute coordinate from the dataset
+ * @return float|int The axis relative coordinate (between 0 and 100)
+ */
+ public function transform($value)
+ {
+ if ($value < $this->min) {
+ return 0;
+ } elseif ($value > $this->max) {
+ return 100;
+ } else {
+ return 100 * ($value - $this->min) / $this->nrOfTicks;
+ }
+ }
+
+ /**
+ * Return the position of the current tick
+ *
+ * @return int
+ */
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ return $this->currentTick;
+ }
+
+ /**
+ * Calculate the next tick and tick value
+ */
+ public function next(): void
+ {
+ $this->currentTick += (100 / $this->nrOfTicks);
+ $this->currentValue += (($this->max - $this->min) / $this->nrOfTicks);
+ }
+
+ /**
+ * Return the label for the current tick
+ *
+ * @return string The label for the current tick
+ */
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return (string) intval($this->currentValue);
+ }
+
+ /**
+ * True when we're at a valid tick (iterator interface)
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ return $this->currentTick >= 0 && $this->currentTick <= 100;
+ }
+
+ /**
+ * Reset the current tick and label value
+ */
+ public function rewind(): void
+ {
+ $this->currentTick = 0;
+ $this->currentValue = $this->min;
+ }
+
+ /**
+ * Set the axis maximum value to a fixed value
+ *
+ * @param int $max The new maximum value
+ */
+ public function setMax($max)
+ {
+ if ($max !== null) {
+ $this->max = $max;
+ $this->staticMax = true;
+ }
+ }
+
+ /**
+ * Set the axis minimum value to a fixed value
+ *
+ * @param int $min The new minimum value
+ */
+ public function setMin($min)
+ {
+ if ($min !== null) {
+ $this->min = $min;
+ $this->staticMin = true;
+ }
+ }
+
+ /**
+ * Return the current minimum value of the axis
+ *
+ * @return int The minimum set for this axis
+ */
+ public function getMin()
+ {
+ return $this->min;
+ }
+
+ /**
+ * Return the current maximum value of the axis
+ *
+ * @return int The maximum set for this axis
+ */
+ public function getMax()
+ {
+ return $this->max;
+ }
+
+ /**
+ * Get the amount of ticks necessary to display this AxisUnit
+ *
+ * @return int
+ */
+ public function getTicks()
+ {
+ return $this->nrOfTicks;
+ }
+}
diff --git a/library/Icinga/Chart/Unit/LogarithmicUnit.php b/library/Icinga/Chart/Unit/LogarithmicUnit.php
new file mode 100644
index 0000000..70961e2
--- /dev/null
+++ b/library/Icinga/Chart/Unit/LogarithmicUnit.php
@@ -0,0 +1,263 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart\Unit;
+
+/**
+ * Logarithmic tick distribution over the axis
+ *
+ * This class does not use the actual logarithm, but a slightly altered version called the
+ * Log-Modulo transformation. This is necessary, since a regular logarithmic scale is not able to display negative
+ * values and zero-points. See <a href="http://blogs.sas.com/content/iml/2014/07/14/log-transformation-of-pos-neg>
+ * this article </a> for a more detailed description.
+ */
+class LogarithmicUnit implements AxisUnit
+{
+ /**
+ * @var int
+ */
+ protected $base;
+
+ /**
+ * @var
+ */
+ protected $currentTick;
+
+ /**
+ * @var
+ */
+ protected $minExp;
+
+ /**
+ * @var
+ */
+ protected $maxExp;
+
+ /**
+ * True when the minimum value is static and isn't affected by the data set
+ *
+ * @var bool
+ */
+ protected $staticMin = false;
+
+ /**
+ * True when the maximum value is static and isn't affected by the data set
+ *
+ * @var bool
+ */
+ protected $staticMax = false;
+
+ /**
+ * Create and initialize this AxisUnit
+ *
+ * @param int $nrOfTicks The number of ticks to use
+ */
+ public function __construct($base = 10)
+ {
+ $this->base = $base;
+ $this->minExp = PHP_INT_MAX;
+ $this->maxExp = ~PHP_INT_MAX;
+ }
+
+ /**
+ * Add a dataset and calculate the minimum and maximum value for this AxisUnit
+ *
+ * @param array $dataset The dataset to add
+ * @param int $idx The idx (0 for x, 1 for y)
+ *
+ * @return $this Fluent interface
+ */
+ public function addValues(array $dataset, $idx = 0)
+ {
+ $datapoints = array();
+
+ foreach ($dataset['data'] as $points) {
+ $datapoints[] = $points[$idx];
+ }
+ if (empty($datapoints)) {
+ return $this;
+ }
+ sort($datapoints);
+ if (!$this->staticMax) {
+ $this->maxExp = max($this->maxExp, $this->logCeil($datapoints[count($datapoints) - 1]));
+ }
+ if (!$this->staticMin) {
+ $this->minExp = min($this->minExp, $this->logFloor($datapoints[0]));
+ }
+ $this->currentTick = 0;
+
+ return $this;
+ }
+
+ /**
+ * Transform the absolute value to an axis relative value
+ *
+ * @param int $value The absolute coordinate from the data set
+ * @return float|int The axis relative coordinate (between 0 and 100)
+ */
+ public function transform($value)
+ {
+ if ($value < $this->pow($this->minExp)) {
+ return 0;
+ } elseif ($value > $this->pow($this->maxExp)) {
+ return 100;
+ } else {
+ return 100 * ($this->log($value) - $this->minExp) / $this->getTicks();
+ }
+ }
+
+ /**
+ * Return the position of the current tick
+ *
+ * @return int
+ */
+ public function current(): int
+ {
+ return $this->currentTick * (100 / $this->getTicks());
+ }
+
+ /**
+ * Calculate the next tick and tick value
+ */
+ public function next(): void
+ {
+ ++ $this->currentTick;
+ }
+
+ /**
+ * Return the label for the current tick
+ *
+ * @return string The label for the current tick
+ */
+ public function key(): string
+ {
+ $currentBase = $this->currentTick + $this->minExp;
+ if (abs($currentBase) > 4) {
+ return $this->base . 'E' . $currentBase;
+ }
+ return (string) intval($this->pow($currentBase));
+ }
+
+ /**
+ * True when we're at a valid tick (iterator interface)
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ return $this->currentTick >= 0 && $this->currentTick < $this->getTicks();
+ }
+
+ /**
+ * Reset the current tick and label value
+ */
+ public function rewind(): void
+ {
+ $this->currentTick = 0;
+ }
+
+ /**
+ * Perform a log-modulo transformation
+ *
+ * @param $value The value to transform
+ *
+ * @return double The transformed value
+ */
+ protected function log($value)
+ {
+ $sign = $value > 0 ? 1 : -1;
+ return $sign * log1p($sign * $value) / log($this->base);
+ }
+
+ /**
+ * Calculate the biggest exponent necessary to display the given data point
+ *
+ * @param $value
+ *
+ * @return float
+ */
+ protected function logCeil($value)
+ {
+ return ceil($this->log($value)) + 1;
+ }
+
+ /**
+ * Calculate the smallest exponent necessary to display the given data point
+ *
+ * @param $value
+ *
+ * @return float
+ */
+ protected function logFloor($value)
+ {
+ return floor($this->log($value));
+ }
+
+ /**
+ * Inverse function to the log-modulo transformation
+ *
+ * @param $value
+ *
+ * @return double
+ */
+ protected function pow($value)
+ {
+ if ($value == 0) {
+ return 0;
+ }
+ $sign = $value > 0 ? 1 : -1;
+ return $sign * (pow($this->base, $sign * $value));
+ }
+
+ /**
+ * Set the axis minimum value to a fixed value
+ *
+ * @param int $min The new minimum value
+ */
+ public function setMin($min)
+ {
+ $this->minExp = $this->logFloor($min);
+ $this->staticMin = true;
+ }
+
+ /**
+ * Set the axis maximum value to a fixed value
+ *
+ * @param int $max The new maximum value
+ */
+ public function setMax($max)
+ {
+ $this->maxExp = $this->logCeil($max);
+ $this->staticMax = true;
+ }
+
+ /**
+ * Return the current minimum value of the axis
+ *
+ * @return int The minimum set for this axis
+ */
+ public function getMin()
+ {
+ return $this->pow($this->minExp);
+ }
+
+ /**
+ * Return the current maximum value of the axis
+ *
+ * @return int The maximum set for this axis
+ */
+ public function getMax()
+ {
+ return $this->pow($this->maxExp);
+ }
+
+ /**
+ * Get the amount of ticks necessary to display this AxisUnit
+ *
+ * @return int
+ */
+ public function getTicks()
+ {
+ return $this->maxExp - $this->minExp;
+ }
+}
diff --git a/library/Icinga/Chart/Unit/StaticAxis.php b/library/Icinga/Chart/Unit/StaticAxis.php
new file mode 100644
index 0000000..6b32aca
--- /dev/null
+++ b/library/Icinga/Chart/Unit/StaticAxis.php
@@ -0,0 +1,130 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+
+namespace Icinga\Chart\Unit;
+
+class StaticAxis implements AxisUnit
+{
+ private $items = array();
+
+ /**
+ * Add a dataset to this AxisUnit, required for dynamic min and max values
+ *
+ * @param array $dataset The dataset that will be shown in the Axis
+ * @param int $idx The idx in the dataset (0 for x, 1 for y)
+ *
+ * @return $this Fluent interface
+ */
+ public function addValues(array $dataset, $idx = 0)
+ {
+ $datapoints = array();
+ foreach ($dataset['data'] as $points) {
+ $this->items[] = $points[$idx];
+ }
+ $this->items = array_unique($this->items);
+
+ return $this;
+ }
+
+ /**
+ * Transform the given absolute value in an axis relative value
+ *
+ * @param int $value The absolute, dataset dependent value
+ *
+ * @return int An axis relative value
+ */
+ public function transform($value)
+ {
+ $flipped = array_flip($this->items);
+ if (!isset($flipped[$value])) {
+ return 0;
+ }
+ $pos = $flipped[$value];
+ return 1 + (99 / count($this->items) * $pos);
+ }
+ /**
+ * Set the axis minimum value to a fixed value
+ *
+ * @param int $min The new minimum value
+ */
+ public function setMin($min)
+ {
+ }
+
+ /**
+ * Set the axis maximum value to a fixed value
+ *
+ * @param int $max The new maximum value
+ */
+ public function setMax($max)
+ {
+ }
+
+ /**
+ * (PHP 5 &gt;= 5.0.0)<br/>
+ * Return the current element
+ * @link http://php.net/manual/en/iterator.current.php
+ * @return int.
+ */
+ public function current(): int
+ {
+ return 1 + (99 / count($this->items) * key($this->items));
+ }
+
+ /**
+ * (PHP 5 &gt;= 5.0.0)<br/>
+ * Move forward to next element
+ * @link http://php.net/manual/en/iterator.next.php
+ * @return void Any returned value is ignored.
+ */
+ public function next(): void
+ {
+ next($this->items);
+ }
+
+ /**
+ * (PHP 5 &gt;= 5.0.0)<br/>
+ * Return the key of the current element
+ * @link http://php.net/manual/en/iterator.key.php
+ * @return mixed scalar on success, or null on failure.
+ */
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return current($this->items);
+ }
+
+ /**
+ * (PHP 5 &gt;= 5.0.0)<br/>
+ * Checks if current position is valid
+ * @link http://php.net/manual/en/iterator.valid.php
+ * @return boolean The return value will be casted to boolean and then evaluated.
+ * Returns true on success or false on failure.
+ */
+ public function valid(): bool
+ {
+ return current($this->items) !== false;
+ }
+
+ /**
+ * (PHP 5 &gt;= 5.0.0)<br/>
+ * Rewind the Iterator to the first element
+ * @link http://php.net/manual/en/iterator.rewind.php
+ * @return void Any returned value is ignored.
+ */
+ public function rewind(): void
+ {
+ reset($this->items);
+ }
+
+ /**
+ * Get the amount of ticks of this axis
+ *
+ * @return int
+ */
+ public function getTicks()
+ {
+ return count($this->items);
+ }
+}
diff --git a/library/Icinga/Cli/AnsiScreen.php b/library/Icinga/Cli/AnsiScreen.php
new file mode 100644
index 0000000..2780f08
--- /dev/null
+++ b/library/Icinga/Cli/AnsiScreen.php
@@ -0,0 +1,122 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Cli;
+
+use Icinga\Cli\Screen;
+use Icinga\Exception\IcingaException;
+
+// @see http://en.wikipedia.org/wiki/ANSI_escape_code
+
+class AnsiScreen extends Screen
+{
+ protected $fgColors = array(
+ 'black' => '30',
+ 'darkgray' => '1;30',
+ 'red' => '31',
+ 'lightred' => '1;31',
+ 'green' => '32',
+ 'lightgreen' => '1;32',
+ 'brown' => '33',
+ 'yellow' => '1;33',
+ 'blue' => '34',
+ 'lightblue' => '1;34',
+ 'purple' => '35',
+ 'lightpurple' => '1;35',
+ 'cyan' => '36',
+ 'lightcyan' => '1;36',
+ 'lightgray' => '37',
+ 'white' => '1;37',
+ );
+
+ protected $bgColors = array(
+ 'black' => '40',
+ 'red' => '41',
+ 'green' => '42',
+ 'brown' => '43',
+ 'blue' => '44',
+ 'purple' => '45',
+ 'cyan' => '46',
+ 'lightgray' => '47',
+ );
+
+ public function strlen($string)
+ {
+ return strlen($this->stripAnsiCodes($string));
+ }
+
+ public function stripAnsiCodes($string)
+ {
+ return preg_replace('/\e\[?.*?[\@-~]/', '', $string);
+ }
+
+ public function clear()
+ {
+ return "\033[2J" // Clear the whole screen
+ . "\033[1;1H" // Move the cursor to row 1, column 1
+ . "\033[1S"; // Scroll whole page up by 1 line (why?)
+ }
+
+ public function underline($text)
+ {
+ return "\033[4m"
+ . $text
+ . "\033[0m"; // Reset color codes
+ }
+
+ public function colorize($text, $fgColor = null, $bgColor = null)
+ {
+ return $this->startColor($fgColor, $bgColor)
+ . $text
+ . "\033[0m"; // Reset color codes
+ }
+
+ protected function fgColor($color)
+ {
+ if (! array_key_exists($color, $this->fgColors)) {
+ throw new IcingaException(
+ 'There is no such foreground color: %s',
+ $color
+ );
+ }
+ return $this->fgColors[$color];
+ }
+
+ protected function bgColor($color)
+ {
+ if (! array_key_exists($color, $this->bgColors)) {
+ throw new IcingaException(
+ 'There is no such background color: %s',
+ $color
+ );
+ }
+ return $this->bgColors[$color];
+ }
+
+ protected function startColor($fgColor = null, $bgColor = null)
+ {
+ $escape = "ESC[";
+ $parts = array();
+ if ($fgColor !== null
+ && $bgColor !== null
+ && ! array_key_exists($bgColor, $this->bgColors)
+ && array_key_exists($bgColor, $this->fgColors)
+ && array_key_exists($fgColor, $this->bgColors)
+ ) {
+ $parts[] = '7'; // reverse video, negative image
+ $parts[] = $this->bgColor($fgColor);
+ $parts[] = $this->fgColor($bgColor);
+ } else {
+ if ($fgColor !== null) {
+ $parts[] = $this->fgColor($fgColor);
+ }
+ if ($bgColor !== null) {
+ $parts[] = $this->bgColor($bgColor);
+ }
+ }
+ if (empty($parts)) {
+ return '';
+ }
+ return "\033[" . implode(';', $parts) . 'm';
+ }
+}
diff --git a/library/Icinga/Cli/Command.php b/library/Icinga/Cli/Command.php
new file mode 100644
index 0000000..7fd5f87
--- /dev/null
+++ b/library/Icinga/Cli/Command.php
@@ -0,0 +1,216 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Cli;
+
+use Icinga\Application\ApplicationBootstrap as App;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotReadableError;
+use ipl\I18n\Translation;
+
+abstract class Command
+{
+ use Translation;
+
+ protected $app;
+ protected $docs;
+
+ /**
+ * @var Params
+ */
+ protected $params;
+ protected $screen;
+
+ /**
+ * Whether the --verbose switch is given and thus the set log level INFO is
+ *
+ * @var bool
+ */
+ protected $isVerbose;
+
+ /**
+ * Whether the --debug switch is given and thus the set log level DEBUG is
+ *
+ * @var bool
+ */
+ protected $isDebugging;
+
+ protected $moduleName;
+ protected $commandName;
+ protected $actionName;
+
+ protected $config;
+
+ protected $configs;
+
+ protected $defaultActionName = 'default';
+
+ /** @var bool Whether to automatically load enabled modules */
+ protected $loadEnabledModules = true;
+
+ /** @var bool Whether to enable trace for the CLI commands */
+ protected $trace = false;
+
+ public function __construct(App $app, $moduleName, $commandName, $actionName, $initialize = true)
+ {
+ $this->app = $app;
+ $this->moduleName = $moduleName;
+ $this->commandName = $commandName;
+ $this->actionName = $actionName;
+ $this->params = $app->getParams();
+ $this->screen = Screen::instance();
+ $this->trace = $this->params->shift('trace', false);
+ $this->isVerbose = $this->params->shift('verbose', false);
+ $this->isDebugging = $this->params->shift('debug', false);
+ $this->configs = [];
+
+ $this->translationDomain = $moduleName ?: 'icinga';
+
+ if ($this->loadEnabledModules) {
+ try {
+ $app->getModuleManager()->loadEnabledModules();
+ } catch (NotReadableError $e) {
+ Logger::error(new IcingaException('Cannot load enabled modules. An exception was thrown:', $e));
+ }
+ }
+
+ if ($initialize) {
+ $this->init();
+ }
+ }
+
+ public function Config($file = null)
+ {
+ if ($this->isModule()) {
+ return $this->getModuleConfig($file);
+ } else {
+ return $this->getMainConfig($file);
+ }
+ }
+
+ private function getModuleConfig($file = null)
+ {
+ if ($file === null) {
+ if ($this->config === null) {
+ $this->config = Config::module($this->moduleName);
+ }
+ return $this->config;
+ } else {
+ if (! array_key_exists($file, $this->configs)) {
+ $this->configs[$file] = Config::module($this->moduleName, $file);
+ }
+ return $this->configs[$file];
+ }
+ }
+
+ private function getMainConfig($file = null)
+ {
+ if ($file === null) {
+ if ($this->config === null) {
+ $this->config = Config::app();
+ }
+ return $this->config;
+ } else {
+ if (! array_key_exists($file, $this->configs)) {
+ $this->configs[$file] = Config::app($file);
+ }
+ return $this->configs[$file];
+ }
+ return $this->config;
+ }
+
+ public function isModule()
+ {
+ return substr(get_class($this), 0, 14) === 'Icinga\\Module\\';
+ }
+
+ public function setParams(Params $params)
+ {
+ $this->params = $params;
+ }
+
+ public function hasRemainingParams()
+ {
+ return $this->params->count() > 0;
+ }
+
+ public function showTrace()
+ {
+ return $this->trace;
+ }
+
+ /**
+ * @param $msg
+ *
+ * @throws IcingaException
+ */
+ public function fail($msg)
+ {
+ throw new IcingaException('%s', $msg);
+ }
+
+ public function getDefaultActionName()
+ {
+ return $this->defaultActionName;
+ }
+
+ /**
+ * Get {@link moduleName}
+ *
+ * @return string
+ */
+ public function getModuleName()
+ {
+ return $this->moduleName;
+ }
+
+ public function hasDefaultActionName()
+ {
+ return $this->hasActionName($this->defaultActionName);
+ }
+
+ public function hasActionName($name)
+ {
+ $actions = $this->listActions();
+ return in_array($name, $actions);
+ }
+
+ public function listActions()
+ {
+ $actions = array();
+ foreach (get_class_methods($this) as $method) {
+ if (preg_match('~^([A-Za-z0-9]+)Action$~', $method, $m)) {
+ $actions[] = $m[1];
+ }
+ }
+ sort($actions);
+ return $actions;
+ }
+
+ public function docs()
+ {
+ if ($this->docs === null) {
+ $this->docs = new Documentation($this->app);
+ }
+ return $this->docs;
+ }
+
+ public function showUsage($action = null)
+ {
+ if ($action === null) {
+ $action = $this->actionName;
+ }
+ echo $this->docs()->usage(
+ $this->moduleName,
+ $this->commandName,
+ $action
+ );
+ return false;
+ }
+
+ public function init()
+ {
+ }
+}
diff --git a/library/Icinga/Cli/Documentation.php b/library/Icinga/Cli/Documentation.php
new file mode 100644
index 0000000..6881467
--- /dev/null
+++ b/library/Icinga/Cli/Documentation.php
@@ -0,0 +1,167 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Cli;
+
+use Icinga\Application\ApplicationBootstrap as App;
+use Icinga\Cli\Documentation\CommentParser;
+use ReflectionClass;
+use ReflectionMethod;
+
+class Documentation
+{
+ protected $icinga;
+
+ protected $app;
+
+ protected $loader;
+
+ public function __construct(App $app)
+ {
+ $this->app = $app;
+ $this->loader = $app->cliLoader();
+ }
+
+ public function usage($module = null, $command = null, $action = null)
+ {
+ if ($module) {
+ $module = $this->loader->resolveModuleName($module);
+ return $this->moduleUsage($module, $command, $action);
+ }
+ if ($command) {
+ $command = $this->loader->resolveCommandName($command);
+ return $this->commandUsage($command, $action);
+ }
+ return $this->globalUsage();
+ }
+
+ public function globalUsage()
+ {
+ $d = "USAGE: icingacli [module] <command> [action] [options]\n\n"
+ . "Available commands:\n\n";
+ foreach ($this->loader->listCommands() as $command) {
+ if ($command !== 'autocomplete') {
+ $obj = $this->loader->getCommandInstance($command);
+ $d .= sprintf(
+ " %-14s %s\n",
+ $command,
+ $this->getClassTitle($obj)
+ );
+ }
+ }
+ $d .= "\nAvailable modules:\n\n";
+ foreach ($this->loader->listModules() as $module) {
+ $d .= ' ' . $module . "\n";
+ }
+ $d .= "\nGlobal options:\n\n"
+ . " --log [t] Log to <t>, either stderr, file or syslog (default: stderr)\n"
+ . " --log-path <f> Which file to log into in case of --log file\n"
+ . " --verbose Be verbose\n"
+ . " --debug Show debug output\n"
+ . " --help Show help\n"
+ . " --benchmark Show benchmark summary\n"
+ . " --watch [s] Refresh output every <s> seconds (default: 5)\n"
+ . " --version Shows version of Icinga Web 2, loaded modules and PHP\n"
+ ;
+ $d .= "\nShow help on a specific command : icingacli help <command>"
+ . "\nShow help on a specific module : icingacli help <module>"
+ . "\n";
+ return $d;
+ }
+
+ public function moduleUsage($module, $command = null, $action = null)
+ {
+ $commands = $this->loader->listModuleCommands($module);
+
+ if (empty($commands)) {
+ return "The '$module' module does not provide any CLI commands\n";
+ }
+ $d = '';
+ $obj = null;
+ if ($command) {
+ $obj = $this->loader->getModuleCommandInstance($module, $command);
+ }
+ if ($command === null) {
+ $d = "USAGE: icingacli $module <command> [<action>] [options]\n\n"
+ . "Available commands:\n\n";
+ foreach ($commands as $command) {
+ $d .= ' ' . $command . "\n";
+ }
+ $d .= "\nShow help on a specific command: icingacli help $module <command>\n";
+ } elseif ($action === null) {
+ $d .= $this->showCommandActions($obj, $command);
+ } else {
+ $action = $this->loader->resolveObjectActionName($obj, $action);
+ $d .= $this->getMethodDocumentation($obj, $action);
+ }
+ return $d;
+ }
+
+ /**
+ * @param Command $command
+ * @param string $name
+ *
+ * @return string
+ */
+ protected function showCommandActions($command, $name)
+ {
+ $actions = $command->listActions();
+ $d = $this->getClassDocumentation($command)
+ . "Available actions:\n\n";
+ foreach ($actions as $action) {
+ $d .= sprintf(
+ " %-14s %s\n",
+ $action,
+ $this->getMethodTitle($command, $action)
+ );
+ }
+ $d .= "\nShow help on a specific action: icingacli help ";
+ if ($command->isModule()) {
+ $d .= $command->getModuleName() . ' ';
+ }
+ $d .= "$name <action>\n";
+ return $d;
+ }
+
+ public function commandUsage($command, $action = null)
+ {
+ $obj = $this->loader->getCommandInstance($command);
+ $action = $this->loader->resolveObjectActionName($obj, $action);
+
+ $d = "\n";
+ if ($action) {
+ $d .= $this->getMethodDocumentation($obj, $action);
+ } else {
+ $d .= $this->showCommandActions($obj, $command);
+ }
+ return $d;
+ }
+
+ protected function getClassTitle($class)
+ {
+ $ref = new ReflectionClass($class);
+ $comment = new CommentParser($ref->getDocComment());
+ return $comment->getTitle();
+ }
+
+ protected function getClassDocumentation($class)
+ {
+ $ref = new ReflectionClass($class);
+ $comment = new CommentParser($ref->getDocComment());
+ return $comment->dump();
+ }
+
+ protected function getMethodTitle($class, $method)
+ {
+ $ref = new ReflectionMethod($class, $method . 'Action');
+ $comment = new CommentParser($ref->getDocComment());
+ return $comment->getTitle();
+ }
+
+ protected function getMethodDocumentation($class, $method)
+ {
+ $ref = new ReflectionMethod($class, $method . 'Action');
+ $comment = new CommentParser($ref->getDocComment());
+ return $comment->dump();
+ }
+}
diff --git a/library/Icinga/Cli/Documentation/CommentParser.php b/library/Icinga/Cli/Documentation/CommentParser.php
new file mode 100644
index 0000000..4104848
--- /dev/null
+++ b/library/Icinga/Cli/Documentation/CommentParser.php
@@ -0,0 +1,85 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Cli\Documentation;
+
+use Icinga\Cli\Screen;
+
+class CommentParser
+{
+ protected $raw;
+ protected $plain;
+ protected $title;
+ protected $paragraphs = array();
+
+ public function __construct($raw)
+ {
+ $this->raw = $raw;
+ if ($raw) {
+ $this->parse();
+ }
+ }
+
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ protected function parse()
+ {
+ $plain = $this->raw;
+
+ // Strip comment start /**
+ $plain = preg_replace('~^/\s*\*\*\n~s', '', $plain);
+
+ // Strip comment end */
+ $plain = preg_replace('~\n\s*\*/\s*~s', "\n", $plain);
+ $p = null;
+ foreach (preg_split('~\n~', $plain) as $line) {
+ // Strip * at line start
+ $line = preg_replace('~^\s*\*\s?~', '', $line);
+ $line = rtrim($line);
+ if ($this->title === null) {
+ $this->title = $line;
+ continue;
+ }
+ if ($p === null && empty($this->paragraphs)) {
+ $p = & $this->paragraphs[];
+ }
+
+ if ($line === '') {
+ if ($p !== null) {
+ $p = & $this->paragraphs[];
+ }
+ continue;
+ }
+ if ($p === null) {
+ $p = $line;
+ } else {
+ if (substr($line, 0, 2) === ' ') {
+ $p .= "\n" . $line;
+ } else {
+ $p .= ' ' . $line;
+ }
+ }
+ }
+ if ($p === null) {
+ array_pop($this->paragraphs);
+ }
+ }
+
+ public function dump()
+ {
+ if ($this->title) {
+ $res = $this->title . "\n" . str_repeat('=', strlen($this->title)) . "\n\n";
+ } else {
+ $res = '';
+ }
+
+ foreach ($this->paragraphs as $p) {
+ $res .= wordwrap($p, Screen::instance()->getColumns()) . "\n\n";
+ }
+
+ return $res;
+ }
+}
diff --git a/library/Icinga/Cli/Loader.php b/library/Icinga/Cli/Loader.php
new file mode 100644
index 0000000..5e63f3f
--- /dev/null
+++ b/library/Icinga/Cli/Loader.php
@@ -0,0 +1,501 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Cli;
+
+use Icinga\Application\ApplicationBootstrap as App;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Cli\Params;
+use Icinga\Cli\Screen;
+use Icinga\Cli\Command;
+use Icinga\Cli\Documentation;
+use Exception;
+
+/**
+ *
+ */
+class Loader
+{
+ protected $app;
+
+ protected $docs;
+
+ protected $commands;
+
+ protected $modules;
+
+ protected $moduleCommands = array();
+
+ protected $coreAppDir;
+
+ protected $screen;
+
+ protected $moduleName;
+
+ protected $commandName;
+
+ protected $actionName; // Should this better be moved to the Command?
+
+ /**
+ * [$command] = $class;
+ */
+ protected $commandClassMap = array();
+
+ /**
+ * [$command] = $file;
+ */
+ protected $commandFileMap = array();
+
+ /**
+ * [$module][$command] = $class;
+ */
+ protected $moduleClassMap = array();
+
+ /**
+ * [$module][$command] = $file;
+ */
+ protected $moduleFileMap = array();
+
+ protected $commandInstances = array();
+
+ protected $moduleInstances = array();
+
+ protected $lastSuggestions = array();
+
+ public function __construct(App $app)
+ {
+ $this->app = $app;
+ $this->coreAppDir = $app->getApplicationDir('clicommands');
+ }
+
+ /**
+ * Screen shortcut
+ *
+ * @return Screen
+ */
+ protected function screen()
+ {
+ if ($this->screen === null) {
+ $this->screen = Screen::instance(STDERR);
+ }
+
+ return $this->screen;
+ }
+
+ /**
+ * Documentation shortcut
+ *
+ * @return Documentation
+ */
+ protected function docs()
+ {
+ if ($this->docs === null) {
+ $this->docs = new Documentation($this->app);
+ }
+ return $this->docs;
+ }
+
+ /**
+ * Show given message and exit
+ *
+ * @param string $msg message to show
+ */
+ public function fail($msg)
+ {
+ fprintf(STDERR, "%s: %s\n", $this->screen()->colorize('ERROR', 'red'), $msg);
+ exit(1);
+ }
+
+ public function getModuleName()
+ {
+ return $this->moduleName;
+ }
+
+ public function setModuleName($name)
+ {
+ $this->moduleName = $name;
+ return $this;
+ }
+
+ public function getCommandName()
+ {
+ return $this->commandName;
+ }
+
+ public function getActionName()
+ {
+ return $this->actionName;
+ }
+
+ public function getCommandInstance($command)
+ {
+ if (! array_key_exists($command, $this->commandInstances)) {
+ $this->assertCommandExists($command);
+ require_once $this->commandFileMap[$command];
+ $className = $this->commandClassMap[$command];
+ $this->commandInstances[$command] = new $className(
+ $this->app,
+ null,
+ $command,
+ null,
+ false
+ );
+ }
+ return $this->commandInstances[$command];
+ }
+
+ public function getModuleCommandInstance($module, $command)
+ {
+ if (! array_key_exists($command, $this->moduleInstances[$module])) {
+ $this->assertModuleCommandExists($module, $command);
+ require_once $this->moduleFileMap[$module][$command];
+ $className = $this->moduleClassMap[$module][$command];
+ $this->moduleInstances[$module][$command] = new $className(
+ $this->app,
+ $module,
+ $command,
+ null,
+ false
+ );
+ }
+ return $this->moduleInstances[$module][$command];
+ }
+
+ public function getLastSuggestions()
+ {
+ return $this->lastSuggestions;
+ }
+
+ public function showLastSuggestions()
+ {
+ if (! empty($this->lastSuggestions)) {
+ foreach ($this->lastSuggestions as & $s) {
+ $s = $this->screen()->colorize($s, 'lightblue');
+ }
+ fprintf(
+ STDERR,
+ "Did you mean %s?\n",
+ implode(" or ", $this->lastSuggestions)
+ );
+ }
+ }
+
+ public function parseParams(Params $params = null)
+ {
+ if ($params === null) {
+ $params = $this->app->getParams();
+ }
+
+ $first = null;
+ if ($this->moduleName === null) {
+ $first = $params->shift();
+ if (! $first) {
+ return;
+ }
+ $found = $this->resolveName($first);
+ } else {
+ $found = $this->moduleName;
+ }
+ if (! $found) {
+ $msg = "There is no such module or command: '$first'";
+ fprintf(STDERR, "%s: %s\n", $this->screen()->colorize('ERROR', 'red'), $msg);
+ $this->showLastSuggestions();
+ fwrite(STDERR, "\n");
+ }
+
+ $obj = null;
+ if ($this->hasCommand($found)) {
+ $this->commandName = $found;
+ $obj = $this->getCommandInstance($this->commandName);
+ } elseif ($this->hasModule($found)) {
+ $this->moduleName = $found;
+ $command = $this->resolveModuleCommandName($found, $params->shift());
+ if ($command) {
+ $this->commandName = $command;
+ $obj = $this->getModuleCommandInstance(
+ $this->moduleName,
+ $this->commandName
+ );
+ }
+ }
+ if ($obj !== null) {
+ $action = $this->resolveObjectActionName(
+ $obj,
+ $params->getStandalone()
+ );
+ if ($obj->hasActionName($action)) {
+ $this->actionName = $action;
+ $params->shift();
+ } elseif ($obj->hasDefaultActionName()) {
+ $this->actionName = $obj->getDefaultActionName();
+ }
+ }
+ return $this;
+ }
+
+ public function handleParams(Params $params = null)
+ {
+ $this->parseParams($params);
+ $this->dispatch();
+ }
+
+ public function dispatch(Params $overrideParams = null)
+ {
+ if ($this->commandName === null) {
+ fwrite(STDERR, $this->docs()->usage($this->moduleName));
+ return false;
+ } elseif ($this->actionName === null) {
+ fwrite(STDERR, $this->docs()->usage($this->moduleName, $this->commandName));
+ return false;
+ }
+
+ $obj = null;
+ try {
+ if ($this->moduleName) {
+ $this->app->getModuleManager()->loadModule($this->moduleName);
+ $obj = $this->getModuleCommandInstance(
+ $this->moduleName,
+ $this->commandName
+ );
+ } else {
+ $obj = $this->getCommandInstance($this->commandName);
+ }
+ if ($overrideParams !== null) {
+ $obj->setParams($overrideParams);
+ }
+ $obj->init();
+ return $obj->{$this->actionName . 'Action'}();
+ } catch (Exception $e) {
+ if ($obj instanceof Command && $obj->showTrace()) {
+ fwrite(STDERR, $this->formatTrace($e->getTrace()));
+ }
+
+ $this->fail(IcingaException::describe($e));
+ }
+ }
+
+ protected function searchMatch($needle, $haystack)
+ {
+ if ($needle === null) {
+ $needle = '';
+ }
+
+ $this->lastSuggestions = preg_grep(sprintf('/^%s.*$/', preg_quote($needle, '/')), $haystack);
+ $match = array_search($needle, $haystack, true);
+ if (false !== $match) {
+ return $haystack[$match];
+ }
+ if (count($this->lastSuggestions) === 1) {
+ $lastSuggestions = array_values($this->lastSuggestions);
+ return $lastSuggestions[0];
+ }
+ return false;
+ }
+
+ public function resolveName($name)
+ {
+ return $this->searchMatch(
+ $name,
+ array_merge($this->listCommands(), $this->listModules())
+ );
+ }
+
+ public function resolveCommandName($name)
+ {
+ return $this->searchMatch($name, $this->listCommands());
+ }
+
+ public function resolveModuleName($name)
+ {
+ return $this->searchMatch($name, $this->listModules());
+ }
+
+ public function resolveModuleCommandName($module, $name)
+ {
+ return $this->searchMatch($name, $this->listModuleCommands($module));
+ }
+
+ public function resolveObjectActionName($obj, $name)
+ {
+ return $this->searchMatch($name, $obj->listActions());
+ }
+
+ protected function assertModuleExists($module)
+ {
+ if (! $this->hasModule($module)) {
+ throw new ProgrammingError(
+ 'There is no such module: %s',
+ $module
+ );
+ }
+ }
+
+ protected function assertCommandExists($command)
+ {
+ if (! $this->hasCommand($command)) {
+ throw new ProgrammingError(
+ 'There is no such command: %s',
+ $command
+ );
+ }
+ }
+
+ protected function assertModuleCommandExists($module, $command)
+ {
+ $this->assertModuleExists($module);
+ if (! $this->hasModuleCommand($module, $command)) {
+ throw new ProgrammingError(
+ 'The module \'%s\' has no such command: %s',
+ $module,
+ $command
+ );
+ }
+ }
+
+ protected function formatTrace($trace)
+ {
+ $output = array();
+ foreach ($trace as $i => $step) {
+ $object = '';
+ if (isset($step['object']) && is_object($step['object'])) {
+ $object = sprintf('[%s]', get_class($step['object'])) . $step['type'];
+ } elseif (! empty($step['object'])) {
+ $object = (string) $step['object'] . $step['type'];
+ }
+ if (isset($step['args']) && is_array($step['args'])) {
+ foreach ($step['args'] as & $arg) {
+ if (is_object($arg)) {
+ $arg = sprintf('[%s]', get_class($arg));
+ }
+ if (is_string($arg)) {
+ $arg = preg_replace('~\n~', '\n', $arg);
+ if (strlen($arg) > 50) {
+ $arg = substr($arg, 0, 47) . '...';
+ }
+ $arg = "'" . $arg . "'";
+ }
+ if ($arg === null) {
+ $arg = 'NULL';
+ }
+ if (is_bool($arg)) {
+ $arg = $arg ? 'TRUE' : 'FALSE';
+ }
+ }
+ } else {
+ $step['args'] = array();
+ }
+ $args = $step['args'];
+ foreach ($args as & $v) {
+ if (is_array($v)) {
+ $v = var_export($v, 1);
+ } else {
+ $v = (string) $v;
+ }
+ }
+ $output[$i] = sprintf(
+ '#%d %s:%d %s%s(%s)',
+ $i,
+ isset($step['file']) ? preg_replace(
+ '~.+/library/~',
+ 'library/',
+ $step['file']
+ ) : '[unknown file]',
+ isset($step['line']) ? $step['line'] : '0',
+ $object,
+ $step['function'],
+ implode(', ', $args)
+ );
+ }
+ return implode(PHP_EOL, $output) . PHP_EOL;
+ }
+
+ public function hasCommand($name)
+ {
+ return in_array($name, $this->listCommands());
+ }
+
+ public function hasModule($name)
+ {
+ return in_array($name, $this->listModules());
+ }
+
+ public function hasModuleCommand($module, $name)
+ {
+ return in_array($name, $this->listModuleCommands($module));
+ }
+
+ public function listModules()
+ {
+ if ($this->modules === null) {
+ $this->modules = array();
+ try {
+ $this->modules = array_unique(array_merge(
+ $this->app->getModuleManager()->listEnabledModules(),
+ $this->app->getModuleManager()->listLoadedModules()
+ ));
+ } catch (NotReadableError $e) {
+ $this->fail($e->getMessage());
+ }
+ }
+ return $this->modules;
+ }
+
+ protected function retrieveCommandsFromDir($dirname)
+ {
+ $commands = array();
+ if (! @file_exists($dirname) || ! is_readable($dirname)) {
+ return $commands;
+ }
+
+ $base = opendir($dirname);
+ if ($base === false) {
+ return $commands;
+ }
+ while (false !== ($dir = readdir($base))) {
+ if ($dir[0] === '.') {
+ continue;
+ }
+ if (preg_match('~^([A-Za-z0-9]+)Command\.php$~', $dir, $m)) {
+ $cmd = strtolower($m[1]);
+ $commands[] = $cmd;
+ }
+ }
+ closedir($base);
+ sort($commands);
+ return $commands;
+ }
+
+ public function listCommands()
+ {
+ if ($this->commands === null) {
+ $this->commands = array();
+ $ns = 'Icinga\\Clicommands\\';
+ $this->commands = $this->retrieveCommandsFromDir($this->coreAppDir);
+ foreach ($this->commands as $cmd) {
+ $this->commandClassMap[$cmd] = $ns . ucfirst($cmd) . 'Command';
+ $this->commandFileMap[$cmd] = $this->coreAppDir . '/' . ucfirst($cmd) . 'Command.php';
+ }
+ }
+ return $this->commands;
+ }
+
+ public function listModuleCommands($module)
+ {
+ if (! array_key_exists($module, $this->moduleCommands)) {
+ $ns = 'Icinga\\Module\\' . ucfirst($module) . '\\Clicommands\\';
+ $this->assertModuleExists($module);
+ $manager = $this->app->getModuleManager();
+ $manager->loadModule($module);
+ $dir = $manager->getModuleDir($module) . '/application/clicommands';
+ $this->moduleCommands[$module] = $this->retrieveCommandsFromDir($dir);
+ $this->moduleInstances[$module] = array();
+ foreach ($this->moduleCommands[$module] as $cmd) {
+ $this->moduleClassMap[$module][$cmd] = $ns . ucfirst($cmd) . 'Command';
+ $this->moduleFileMap[$module][$cmd] = $dir . '/' . ucfirst($cmd) . 'Command.php';
+ }
+ }
+ return $this->moduleCommands[$module];
+ }
+}
diff --git a/library/Icinga/Cli/Params.php b/library/Icinga/Cli/Params.php
new file mode 100644
index 0000000..463d4ae
--- /dev/null
+++ b/library/Icinga/Cli/Params.php
@@ -0,0 +1,320 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Cli;
+
+use Icinga\Exception\MissingParameterException;
+
+/**
+ * Params
+ *
+ * A class to ease commandline-option and -argument handling.
+ */
+class Params
+{
+ /**
+ * The name and path of the executable
+ *
+ * @var string
+ */
+ protected $program;
+
+ /**
+ * The arguments
+ *
+ * @var array
+ */
+ protected $standalone = array();
+
+ /**
+ * The options
+ *
+ * @var array
+ */
+ protected $params = array();
+
+ /**
+ * Parse the given commandline and create a new Params object
+ *
+ * @param array $argv The commandline
+ */
+ public function __construct($argv)
+ {
+ $noOptionFlag = false;
+ $this->program = array_shift($argv);
+ for ($i = 0; $i < count($argv); $i++) {
+ if ($argv[$i] === '--') {
+ $noOptionFlag = true;
+ } elseif (!$noOptionFlag && substr($argv[$i], 0, 2) === '--') {
+ $key = substr($argv[$i], 2);
+ $matches = array();
+ if (1 === preg_match(
+ '/(?<!.)([^=]+)=(.*)(?!.)/ms',
+ $key,
+ $matches
+ )) {
+ $this->params[$matches[1]] = $matches[2];
+ } elseif (! isset($argv[$i + 1]) || substr($argv[$i + 1], 0, 2) === '--') {
+ $this->params[$key] = true;
+ } elseif (array_key_exists($key, $this->params)) {
+ if (!is_array($this->params[$key])) {
+ $this->params[$key] = array($this->params[$key]);
+ }
+ $this->params[$key][] = $argv[++$i];
+ } else {
+ $this->params[$key] = $argv[++$i];
+ }
+ } else {
+ $this->standalone[] = $argv[$i];
+ }
+ }
+ }
+
+ /**
+ * Return the value for an argument by position
+ *
+ * @param int $pos The position of the argument
+ * @param mixed $default The default value to return
+ *
+ * @return mixed
+ */
+ public function getStandalone($pos = 0, $default = null)
+ {
+ if (isset($this->standalone[$pos])) {
+ return $this->standalone[$pos];
+ }
+ return $default;
+ }
+
+ /**
+ * Count and return the number of arguments and options
+ *
+ * @return int
+ */
+ public function count()
+ {
+ return count($this->standalone) + count($this->params);
+ }
+
+ /**
+ * Return the options
+ *
+ * @return array
+ */
+ public function getParams()
+ {
+ return $this->params;
+ }
+
+ /**
+ * Return the arguments
+ *
+ * @return array
+ */
+ public function getAllStandalone()
+ {
+ return $this->standalone;
+ }
+
+ /**
+ * Support isset() and empty() checks on options
+ *
+ * @param $name
+ *
+ * @return bool
+ */
+ public function __isset($name)
+ {
+ return isset($this->params[$name]);
+ }
+
+ /**
+ * @see Params::get()
+ */
+ public function __get($key)
+ {
+ return $this->get($key);
+ }
+
+ /**
+ * Return whether the given option exists
+ *
+ * @param string $key The option name to check
+ *
+ * @return bool
+ */
+ public function has($key)
+ {
+ return array_key_exists($key, $this->params);
+ }
+
+ /**
+ * Return the value of the given option
+ *
+ * @param string $key The option name
+ * @param mixed $default The default value to return
+ *
+ * @return mixed
+ */
+ public function get($key, $default = null)
+ {
+ if ($this->has($key)) {
+ return $this->params[$key];
+ }
+ return $default;
+ }
+
+ /**
+ * Require a parameter
+ *
+ * @param string $name Name of the parameter
+ * @param bool $strict Whether the parameter's value must not be the empty string
+ *
+ * @return mixed
+ *
+ * @throws MissingParameterException If the parameter was not given
+ */
+ public function getRequired($name, $strict = true)
+ {
+ if ($this->has($name)) {
+ $value = $this->get($name);
+ if (! $strict || strlen($value) > 0) {
+ return $value;
+ }
+ }
+ $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name);
+ $e->setParameter($name);
+ throw $e;
+ }
+
+ /**
+ * Set a value for the given option
+ *
+ * @param string $key The option name
+ * @param mixed $value The value to set
+ *
+ * @return $this
+ */
+ public function set($key, $value)
+ {
+ $this->params[$key] = $value;
+ return $this;
+ }
+
+ /**
+ * Remove a single option or multiple options
+ *
+ * @param string|array $keys The option or options to remove
+ *
+ * @return $this
+ */
+ public function remove($keys = array())
+ {
+ if (! is_array($keys)) {
+ $keys = array($keys);
+ }
+ foreach ($keys as $key) {
+ if (array_key_exists($key, $this->params)) {
+ unset($this->params[$key]);
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Return a copy of this object with the given options being removed
+ *
+ * @param string|array $keys The option or options to remove
+ *
+ * @return Params
+ */
+ public function without($keys = array())
+ {
+ $params = clone($this);
+ return $params->remove($keys);
+ }
+
+ /**
+ * Remove and return the value of the given option
+ *
+ * Called multiple times for an option with multiple values returns
+ * them one by one in case the default is not an array.
+ *
+ * @param string $key The option name
+ * @param mixed $default The default value to return
+ *
+ * @return mixed
+ */
+ public function shift($key = null, $default = null)
+ {
+ if ($key === null) {
+ if (count($this->standalone) > 0) {
+ return array_shift($this->standalone);
+ }
+ return $default;
+ }
+ $result = $this->get($key, $default);
+ if (is_array($result) && !is_array($default)) {
+ $result = array_shift($result) || $default;
+ if ($result === $default) {
+ $this->remove($key);
+ }
+ } else {
+ $this->remove($key);
+ }
+ return $result;
+ }
+
+ /**
+ * Require and remove a parameter
+ *
+ * @param string $name Name of the parameter
+ * @param bool $strict Whether the parameter's value must not be the empty string
+ *
+ * @return mixed
+ *
+ * @throws MissingParameterException If the parameter was not given
+ */
+ public function shiftRequired($name, $strict = true)
+ {
+ if ($this->has($name)) {
+ $value = $this->get($name);
+ if (! $strict || strlen($value) > 0) {
+ $this->shift($name);
+ return $value;
+ }
+ }
+ $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name);
+ $e->setParameter($name);
+ throw $e;
+ }
+
+ /**
+ * Put the given value onto the argument stack
+ *
+ * @param mixed $key The argument
+ *
+ * @return $this
+ */
+ public function unshift($key)
+ {
+ array_unshift($this->standalone, $key);
+ return $this;
+ }
+
+ /**
+ * Parse the given commandline
+ *
+ * @param array $argv The commandline to parse
+ *
+ * @return Params
+ */
+ public static function parse($argv = null)
+ {
+ if ($argv === null) {
+ $argv = $GLOBALS['argv'];
+ }
+ $params = new self($argv);
+ return $params;
+ }
+}
diff --git a/library/Icinga/Cli/Screen.php b/library/Icinga/Cli/Screen.php
new file mode 100644
index 0000000..4ffad72
--- /dev/null
+++ b/library/Icinga/Cli/Screen.php
@@ -0,0 +1,106 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Cli;
+
+use Icinga\Cli\AnsiScreen;
+
+class Screen
+{
+ protected static $instances = [];
+
+ protected $isUtf8;
+
+ public function getColumns()
+ {
+ $cols = (int) getenv('COLUMNS');
+ if (! $cols) {
+ // stty -a ?
+ $cols = (int) exec('tput cols');
+ }
+ if (! $cols) {
+ $cols = 80;
+ }
+ return $cols;
+ }
+
+ public function getRows()
+ {
+ $rows = (int) getenv('ROWS');
+ if (! $rows) {
+ // stty -a ?
+ $rows = (int) exec('tput lines');
+ }
+ if (! $rows) {
+ $rows = 25;
+ }
+ return $rows;
+ }
+
+ public function strlen($string)
+ {
+ return strlen($string);
+ }
+
+ public function newlines($count = 1)
+ {
+ return str_repeat("\n", $count);
+ }
+
+ public function center($txt)
+ {
+ $len = $this->strlen($txt);
+ $width = floor(($this->getColumns() + $len) / 2) - $len;
+ return str_repeat(' ', $width) . $txt;
+ }
+
+ public function hasUtf8()
+ {
+ if ($this->isUtf8 === null) {
+ // null should equal 0 here, however seems to equal '' on some systems:
+ $current = setlocale(LC_ALL, 0);
+
+ $parts = preg_split('/;/', $current);
+ $lc_parts = array();
+ foreach ($parts as $part) {
+ if (strpos($part, '=') === false) {
+ continue;
+ }
+ list($key, $val) = preg_split('/=/', $part, 2);
+ $lc_parts[$key] = $val;
+ }
+
+ $this->isUtf8 = array_key_exists('LC_CTYPE', $lc_parts)
+ && preg_match('~\.UTF-8$~i', $lc_parts['LC_CTYPE']);
+ }
+ return $this->isUtf8;
+ }
+
+ public function clear()
+ {
+ return "\n";
+ }
+
+ public function underline($text)
+ {
+ return $text;
+ }
+
+ public function colorize($text, $fgColor = null, $bgColor = null)
+ {
+ return $text;
+ }
+
+ public static function instance($output = STDOUT)
+ {
+ if (! isset(self::$instances[(int) $output])) {
+ if (function_exists('posix_isatty') && posix_isatty($output)) {
+ self::$instances[(int) $output] = new AnsiScreen();
+ } else {
+ self::$instances[(int) $output] = new Screen();
+ }
+ }
+
+ return self::$instances[(int) $output];
+ }
+}
diff --git a/library/Icinga/Common/Database.php b/library/Icinga/Common/Database.php
new file mode 100644
index 0000000..d54eb25
--- /dev/null
+++ b/library/Icinga/Common/Database.php
@@ -0,0 +1,56 @@
+<?php
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Common;
+
+use Icinga\Application\Config as IcingaConfig;
+use Icinga\Data\ResourceFactory;
+use ipl\Sql\Config as SqlConfig;
+use ipl\Sql\Connection;
+use LogicException;
+use PDO;
+
+/**
+ * Trait for accessing the Icinga Web database
+ */
+trait Database
+{
+ /**
+ * Get a connection to the Icinga Web database
+ *
+ * @return Connection
+ *
+ * @throws \Icinga\Exception\ConfigurationError
+ */
+ protected function getDb(): Connection
+ {
+ if (! $this->hasDb()) {
+ throw new LogicException('Please check if a db instance exists at all');
+ }
+
+ $config = new SqlConfig(ResourceFactory::getResourceConfig(
+ IcingaConfig::app()->get('global', 'config_resource')
+ ));
+ if ($config->db === 'mysql') {
+ $config->charset = 'utf8mb4';
+ }
+
+ $config->options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ];
+ if ($config->db === 'mysql') {
+ $config->options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION SQL_MODE='STRICT_TRANS_TABLES"
+ . ",NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'";
+ }
+
+ return new Connection($config);
+ }
+
+ /**
+ * Check if db exists
+ *
+ * @return bool true if a database was found otherwise false
+ */
+ protected function hasDb()
+ {
+ return (bool) IcingaConfig::app()->get('global', 'config_resource');
+ }
+}
diff --git a/library/Icinga/Common/PdfExport.php b/library/Icinga/Common/PdfExport.php
new file mode 100644
index 0000000..afea9bf
--- /dev/null
+++ b/library/Icinga/Common/PdfExport.php
@@ -0,0 +1,105 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Common;
+
+use Icinga\Application\Icinga;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Util\Environment;
+use Icinga\Web\Controller;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Html\ValidHtml;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Url;
+
+trait PdfExport
+{
+ /** @var string The image to show in a pdf exports page header */
+ private $pdfHeaderImage = 'img/icinga-logo-big-dark.png';
+
+ /**
+ * Export the requested action to PDF and send it
+ *
+ * @return never
+ * @throws ConfigurationError If the pdfexport module is not available
+ */
+ protected function sendAsPdf()
+ {
+ if (! Icinga::app()->getModuleManager()->has('pdfexport')) {
+ throw new ConfigurationError('The pdfexport module is required for exports to PDF');
+ }
+
+ putenv('ICINGAWEB_EXPORT_FORMAT=pdf');
+ Environment::raiseMemoryLimit('512M');
+ Environment::raiseExecutionTime(300);
+
+ $time = DateFormatter::formatDateTime(time());
+ $iconPath = is_readable($this->pdfHeaderImage)
+ ? $this->pdfHeaderImage
+ : Icinga::app()->getBootstrapDirectory() . '/' . $this->pdfHeaderImage;
+ $encodedIcon = is_readable($iconPath) ? base64_encode(file_get_contents($iconPath)) : null;
+ $html = $this instanceof CompatController && ! $this->content->isEmpty()
+ ? $this->content
+ : $this->renderControllerAction();
+
+ $doc = (new PrintableHtmlDocument())
+ ->setTitle($this->view->title)
+ ->setHeader(Html::wantHtml([
+ Html::tag('span', ['class' => 'title']),
+ $encodedIcon
+ ? Html::tag('img', ['height' => 13, 'src' => 'data:image/png;base64,' . $encodedIcon])
+ : null,
+ Html::tag('time', null, $time)
+ ]))
+ ->setFooter(Html::wantHtml([
+ Html::tag('span', null, [
+ t('Page') . ' ',
+ Html::tag('span', ['class' => 'pageNumber']),
+ ' / ',
+ Html::tag('span', ['class' => 'totalPages'])
+ ]),
+ Html::tag('p', null, rawurldecode(Url::fromRequest()->setParams($this->params)))
+ ]))
+ ->addHtml($html);
+
+ if (($moduleName = $this->getRequest()->getModuleName()) !== 'default') {
+ $doc->getAttributes()->add('class', 'icinga-module module-' . $moduleName);
+ }
+
+ \Icinga\Module\Pdfexport\ProvidedHook\Pdfexport::first()->streamPdfFromHtml($doc, sprintf(
+ '%s-%s',
+ $this->view->title ?: $this->getRequest()->getActionName(),
+ $time
+ ));
+ }
+
+ /**
+ * Render the requested action
+ *
+ * @return ValidHtml
+ */
+ protected function renderControllerAction()
+ {
+ /** @var Controller $this */
+ $this->view->compact = true;
+
+ $viewRenderer = $this->getHelper('viewRenderer');
+ $viewRenderer->postDispatch();
+
+ $layoutHelper = $this->getHelper('layout');
+ $oldLayout = $layoutHelper->getLayout();
+ $layout = $layoutHelper->setLayout('inline');
+
+ $layout->content = $this->getResponse();
+ $html = $layout->render();
+
+ // Restore previous layout and reset content, to properly show errors
+ $this->getResponse()->clearBody($viewRenderer->getResponseSegment());
+ $layoutHelper->setLayout($oldLayout);
+
+ return HtmlString::create($html);
+ }
+}
diff --git a/library/Icinga/Crypt/AesCrypt.php b/library/Icinga/Crypt/AesCrypt.php
new file mode 100644
index 0000000..8e9d453
--- /dev/null
+++ b/library/Icinga/Crypt/AesCrypt.php
@@ -0,0 +1,337 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Crypt;
+
+use UnexpectedValueException;
+use RuntimeException;
+
+/**
+ * Data encryption and decryption using symmetric algorithm
+ *
+ * # Example Usage
+ *
+ * ```php
+ *
+ * // Encryption
+ * $encryptedData = (new AesCrypt())->encrypt($data); // Accepts a string
+ *
+ *
+ * // Encrypt and encode to Base64
+ * $encryptedData = (new AesCrypt())->encryptToBase64($data); // Accepts a string
+ *
+ *
+ * // Decryption
+ * $aesCrypt = (new AesCrypt())
+ * ->setTag($tag) // if exists
+ * ->setIV($iv)
+ * ->setKey($key);
+ *
+ * $decryptedData = $aesCrypt->decrypt($data);
+ *
+ * // Decode from Base64 and decrypt
+ * $aesCrypt = (new AesCrypt())
+ * ->setTag($tag)
+ * ->setIV($iv)
+ * ->setKey($key);
+ *
+ * $decryptedData = $aesCrypt->decryptFromBase64($data);
+ * ```
+ *
+ */
+class AesCrypt
+{
+ /** @var array The list of cipher methods */
+ const METHODS = [
+ 'aes-256-gcm',
+ 'aes-256-cbc',
+ 'aes-256-ctr'
+ ];
+
+ /** @var string The encryption key */
+ private $key;
+
+ /** @var int The length of the key */
+ private $keyLength;
+
+ /** @var string The initialization vector which is not NULL */
+ private $iv;
+
+ /** @var string The authentication tag which is passed by reference when using AEAD cipher mode */
+ private $tag;
+
+ /** @var string The cipher method */
+ private $method;
+
+ public function __construct($keyLength = 128)
+ {
+ $this->keyLength = $keyLength;
+ }
+
+ /**
+ * Set the method
+ *
+ * @return $this
+ */
+ public function setMethod($method)
+ {
+ $this->method = $method;
+
+ return $this;
+ }
+
+ /**
+ * Get the method
+ *
+ * @return string
+ */
+ public function getMethod()
+ {
+ if ($this->method === null) {
+ $this->method = $this->getSupportedMethod();
+ }
+
+ return $this->method;
+ }
+
+ /**
+ * Get supported method
+ *
+ * @return string
+ *
+ * @throws RuntimeException If none of the methods listed in the METHODS array is available
+ */
+ protected function getSupportedMethod()
+ {
+ $availableMethods = openssl_get_cipher_methods();
+ $methods = self::METHODS;
+
+ if (! $this->isAuthenticatedEncryptionSupported()) {
+ unset($methods[0]);
+ }
+
+ foreach ($methods as $method) {
+ if (in_array($method, $availableMethods)) {
+ return $method;
+ }
+ }
+
+ throw new RuntimeException('No supported method found');
+ }
+
+ /**
+ * Set the key
+ *
+ * @return $this
+ */
+ public function setKey($key)
+ {
+ $this->key = $key;
+
+ return $this;
+ }
+
+ /**
+ * Get the key
+ *
+ * @return string
+ *
+ */
+ public function getKey()
+ {
+ if (empty($this->key)) {
+ $this->key = random_bytes($this->keyLength);
+ }
+
+ return $this->key;
+ }
+
+ /**
+ * Set the IV
+ *
+ * @return $this
+ */
+ public function setIV($iv)
+ {
+ $this->iv = $iv;
+
+ return $this;
+ }
+
+ /**
+ * Get the IV
+ *
+ * @return string
+ *
+ */
+ public function getIV()
+ {
+ if (empty($this->iv)) {
+ $len = openssl_cipher_iv_length($this->getMethod());
+ $this->iv = random_bytes($len);
+ }
+
+ return $this->iv;
+ }
+
+ /**
+ * Set the Tag
+ *
+ * @return $this
+ *
+ * @throws RuntimeException If a tag is available but authenticated encryption (AE) is not supported.
+ *
+ * @throws UnexpectedValueException If tag length is less then 16
+ */
+ public function setTag($tag)
+ {
+ if (! $this->isAuthenticatedEncryptionSupported()) {
+ throw new RuntimeException(sprintf(
+ "The given decryption method is not supported in php version '%s'",
+ PHP_VERSION
+ ));
+ }
+
+ if (strlen($tag) !== 16) {
+ throw new UnexpectedValueException(sprintf(
+ 'expects tag length to be 16, got instead %s',
+ strlen($tag)
+ ));
+ }
+
+ $this->tag = $tag;
+
+ return $this;
+ }
+
+ /**
+ * Get the Tag
+ *
+ * @return string
+ *
+ * @throws RuntimeException If the Tag is not set
+ */
+ public function getTag()
+ {
+ if (empty($this->tag)) {
+ throw new RuntimeException('No tag set');
+ }
+
+ return $this->tag;
+ }
+
+ /**
+ * Decrypt the given string
+ *
+ * @param string $data
+ *
+ * @return string
+ *
+ * @throws RuntimeException If decryption fails
+ */
+ public function decrypt($data)
+ {
+ if (! $this->isAuthenticatedEncryptionRequired()) {
+ return $this->nonAEDecrypt($data);
+ }
+
+ $decrypt = openssl_decrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV(), $this->getTag());
+
+ if ($decrypt === false) {
+ throw new RuntimeException('Decryption failed');
+ }
+
+ return $decrypt;
+ }
+
+ /**
+ * Encrypt the given string
+ *
+ * @param string $data
+ *
+ * @return string encrypted string
+ *
+ * @throws RuntimeException If decryption fails
+ */
+ public function encrypt($data)
+ {
+ if (! $this->isAuthenticatedEncryptionRequired()) {
+ return $this->nonAEEncrypt($data);
+ }
+
+ $encrypt = openssl_encrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV(), $this->tag);
+
+ if ($encrypt === false) {
+ throw new RuntimeException('Encryption failed');
+ }
+
+ return $encrypt;
+ }
+
+ /**
+ * Decrypt the given string with non Authenticated encryption (AE) cipher method
+ *
+ * @param string $data
+ *
+ * @return string decrypted string
+ *
+ * @throws RuntimeException If decryption fails
+ */
+ private function nonAEDecrypt($data)
+ {
+ $c = base64_decode($data);
+ $hmac = substr($c, 0, 32);
+ $data = substr($c, 32);
+
+ $decrypt = openssl_decrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV());
+ $calcHmac = hash_hmac('sha256', $this->getIV() . $data, $this->getKey(), true);
+
+ if ($decrypt === false || ! hash_equals($hmac, $calcHmac)) {
+ throw new RuntimeException('Decryption failed');
+ }
+
+ return $decrypt;
+ }
+
+ /**
+ * Encrypt the given string with non Authenticated encryption (AE) cipher method
+ *
+ * @param string $data
+ *
+ * @return string encrypted string
+ *
+ * @throws RuntimeException If encryption fails
+ */
+ private function nonAEEncrypt($data)
+ {
+ $encrypt = openssl_encrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV());
+
+ if ($encrypt === false) {
+ throw new RuntimeException('Encryption failed');
+ }
+
+ $hmac = hash_hmac('sha256', $this->getIV() . $encrypt, $this->getKey(), true);
+
+ return base64_encode($hmac . $encrypt);
+ }
+
+ /**
+ * Whether the Authenticated encryption (a tag) is required
+ *
+ * @return bool True if required false otherwise
+ */
+ public function isAuthenticatedEncryptionRequired()
+ {
+ return $this->getMethod() === 'aes-256-gcm';
+ }
+
+ /**
+ * Whether the php version supports Authenticated encryption (AE) or not
+ *
+ * @return bool True if supported false otherwise
+ */
+ public function isAuthenticatedEncryptionSupported()
+ {
+ return PHP_VERSION_ID >= 70100;
+ }
+}
diff --git a/library/Icinga/Data/ConfigObject.php b/library/Icinga/Data/ConfigObject.php
new file mode 100644
index 0000000..c9a3134
--- /dev/null
+++ b/library/Icinga/Data/ConfigObject.php
@@ -0,0 +1,289 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Iterator;
+use ArrayAccess;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Container for configuration values
+ */
+class ConfigObject extends ArrayDatasource implements Iterator, ArrayAccess
+{
+ /**
+ * Create a new config
+ *
+ * @param array $data The data to initialize the new config with
+ */
+ public function __construct(array $data = array())
+ {
+ // Convert all embedded arrays to ConfigObjects as well
+ foreach ($data as & $value) {
+ if (is_array($value)) {
+ $value = new static($value);
+ }
+ }
+
+ parent::__construct($data);
+ }
+
+ /**
+ * Deep clone this config
+ */
+ public function __clone()
+ {
+ $array = array();
+ foreach ($this->data as $key => $value) {
+ if ($value instanceof self) {
+ $array[$key] = clone $value;
+ } else {
+ $array[$key] = $value;
+ }
+ }
+
+ $this->data = $array;
+ }
+
+ /**
+ * Reset the current position of $this->data
+ *
+ * @return void
+ */
+ public function rewind(): void
+ {
+ reset($this->data);
+ }
+
+ /**
+ * Return the section's or property's value of the current iteration
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ return current($this->data);
+ }
+
+ /**
+ * Return whether the position of the current iteration is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ return key($this->data) !== null;
+ }
+
+ /**
+ * Return the section's or property's name of the current iteration
+ *
+ * @return string
+ */
+ public function key(): string
+ {
+ return key($this->data);
+ }
+
+ /**
+ * Advance the position of the current iteration and return the new section's or property's value
+ *
+ * @return void
+ */
+ public function next(): void
+ {
+ next($this->data);
+ }
+
+ /**
+ * Return whether the given section or property is set
+ *
+ * @param string $key The name of the section or property
+ *
+ * @return bool
+ */
+ public function __isset($key)
+ {
+ return isset($this->data[$key]);
+ }
+
+ /**
+ * Return the value for the given property or the config for the given section
+ *
+ * @param string $key The name of the property or section
+ *
+ * @return mixed|NULL The value or NULL in case $key does not exist
+ */
+ public function __get($key)
+ {
+ return $this->get($key);
+ }
+
+ /**
+ * Add a new property or section
+ *
+ * @param string $key The name of the new property or section
+ * @param mixed $value The value to set for the new property or section
+ */
+ public function __set($key, $value)
+ {
+ if (is_array($value)) {
+ $this->data[$key] = new static($value);
+ } else {
+ $this->data[$key] = $value;
+ }
+ }
+
+ /**
+ * Remove the given property or section
+ *
+ * @param string $key The property or section to remove
+ */
+ public function __unset($key)
+ {
+ unset($this->data[$key]);
+ }
+
+ /**
+ * Return whether the given section or property is set
+ *
+ * @param string $key The name of the section or property
+ *
+ * @return bool
+ */
+ public function offsetExists($key): bool
+ {
+ return isset($this->$key);
+ }
+
+ /**
+ * Return the value for the given property or the config for the given section
+ *
+ * @param string $key The name of the property or section
+ *
+ * @return ?mixed The value or NULL in case $key does not exist
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetGet($key)
+ {
+ return $this->get($key);
+ }
+
+ /**
+ * Add a new property or section
+ *
+ * @param string $key The name of the new property or section
+ * @param mixed $value The value to set for the new property or section
+ *
+ * @throws ProgrammingError If the key is null
+ */
+ public function offsetSet($key, $value): void
+ {
+ if ($key === null) {
+ throw new ProgrammingError('Appending values without an explicit key is not supported');
+ }
+
+ $this->$key = $value;
+ }
+
+ /**
+ * Remove the given property or section
+ *
+ * @param string $key The property or section to remove
+ */
+ public function offsetUnset($key): void
+ {
+ unset($this->$key);
+ }
+
+ /**
+ * Return whether this config has any data
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return empty($this->data);
+ }
+
+ /**
+ * Return the value for the given property or the config for the given section
+ *
+ * @param string $key The name of the property or section
+ * @param mixed $default The value to return in case the property or section is missing
+ *
+ * @return mixed
+ */
+ public function get($key, $default = null)
+ {
+ if (array_key_exists($key, $this->data)) {
+ return $this->data[$key];
+ }
+
+ return $default;
+ }
+
+ /**
+ * Return all section and property names
+ *
+ * @return array
+ */
+ public function keys()
+ {
+ return array_keys($this->data);
+ }
+
+ /**
+ * Return this config's data as associative array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $array = array();
+ foreach ($this->data as $key => $value) {
+ if ($value instanceof self) {
+ $array[$key] = $value->toArray();
+ } else {
+ $array[$key] = $value;
+ }
+ }
+
+ return $array;
+ }
+
+ /**
+ * Merge the given data with this config
+ *
+ * @param array|ConfigObject $data An array or a config
+ *
+ * @return $this
+ */
+ public function merge($data)
+ {
+ if ($data instanceof self) {
+ $data = $data->toArray();
+ }
+
+ foreach ($data as $key => $value) {
+ if (array_key_exists($key, $this->data)) {
+ if (is_array($value)) {
+ if ($this->data[$key] instanceof self) {
+ $this->data[$key]->merge($value);
+ } else {
+ $this->data[$key] = new static($value);
+ }
+ } else {
+ $this->data[$key] = $value;
+ }
+ } else {
+ $this->data[$key] = is_array($value) ? new static($value) : $value;
+ }
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Data/ConnectionInterface.php b/library/Icinga/Data/ConnectionInterface.php
new file mode 100644
index 0000000..bd7d026
--- /dev/null
+++ b/library/Icinga/Data/ConnectionInterface.php
@@ -0,0 +1,8 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+interface ConnectionInterface extends Selectable, Queryable
+{
+}
diff --git a/library/Icinga/Data/DataArray/ArrayDatasource.php b/library/Icinga/Data/DataArray/ArrayDatasource.php
new file mode 100644
index 0000000..e300616
--- /dev/null
+++ b/library/Icinga/Data/DataArray/ArrayDatasource.php
@@ -0,0 +1,292 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\DataArray;
+
+use ArrayIterator;
+use Icinga\Data\Selectable;
+use Icinga\Data\SimpleQuery;
+
+class ArrayDatasource implements Selectable
+{
+ /**
+ * The array being used as data source
+ *
+ * @var array
+ */
+ protected $data;
+
+ /**
+ * The current result
+ *
+ * @var array
+ */
+ protected $result;
+
+ /**
+ * The result of a counted query
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * The name of the column to map array keys on
+ *
+ * In case the array being used as data source provides keys of type string,this name
+ * will be used to set such as column on each row, if the column is not set already.
+ *
+ * @var string
+ */
+ protected $keyColumn;
+
+ /**
+ * Create a new data source for the given array
+ *
+ * @param array $data The array you're going to use as a data source
+ */
+ public function __construct(array $data)
+ {
+ $this->data = $data;
+ }
+
+ /**
+ * Set the name of the column to map array keys on
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setKeyColumn($name)
+ {
+ $this->keyColumn = $name;
+ return $this;
+ }
+
+ /**
+ * Return the name of the column to map array keys on
+ *
+ * @return string
+ */
+ public function getKeyColumn()
+ {
+ return $this->keyColumn;
+ }
+
+ /**
+ * Provide a query for this data source
+ *
+ * @return SimpleQuery
+ */
+ public function select()
+ {
+ return new SimpleQuery(clone $this);
+ }
+
+ /**
+ * Fetch and return all rows of the given query's result set using an iterator
+ *
+ * @param SimpleQuery $query
+ *
+ * @return ArrayIterator
+ */
+ public function query(SimpleQuery $query)
+ {
+ return new ArrayIterator($this->fetchAll($query));
+ }
+
+ /**
+ * Fetch and return a column of all rows of the result set as an array
+ *
+ * @param SimpleQuery $query
+ *
+ * @return array
+ */
+ public function fetchColumn(SimpleQuery $query)
+ {
+ $result = array();
+ foreach ($this->getResult($query) as $row) {
+ $arr = (array) $row;
+ $result[] = array_shift($arr);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch and return all rows of the given query's result as a flattened key/value based array
+ *
+ * @param SimpleQuery $query
+ *
+ * @return array
+ */
+ public function fetchPairs(SimpleQuery $query)
+ {
+ $result = array();
+ $keys = null;
+ foreach ($this->getResult($query) as $row) {
+ if ($keys === null) {
+ $keys = array_keys((array) $row);
+ if (count($keys) < 2) {
+ $keys[1] = $keys[0];
+ }
+ }
+
+ $result[$row->{$keys[0]}] = $row->{$keys[1]};
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch and return the first row of the given query's result
+ *
+ * @param SimpleQuery $query
+ *
+ * @return object|false The row or false in case the result is empty
+ */
+ public function fetchRow(SimpleQuery $query)
+ {
+ $result = $this->getResult($query);
+ if (empty($result)) {
+ return false;
+ }
+
+ return array_shift($result);
+ }
+
+ /**
+ * Fetch and return all rows of the given query's result as an array
+ *
+ * @param SimpleQuery $query
+ *
+ * @return array
+ */
+ public function fetchAll(SimpleQuery $query)
+ {
+ return $this->getResult($query);
+ }
+
+ /**
+ * Count all rows of the given query's result
+ *
+ * @param SimpleQuery $query
+ *
+ * @return int
+ */
+ public function count(SimpleQuery $query)
+ {
+ if ($this->count === null) {
+ $this->count = count($this->createResult($query));
+ }
+
+ return $this->count;
+ }
+
+ /**
+ * Create and return the result for the given query
+ *
+ * @param SimpleQuery $query
+ *
+ * @return array
+ */
+ protected function createResult(SimpleQuery $query)
+ {
+ $columns = $query->getColumns();
+ $filter = $query->getFilter();
+ $offset = $query->hasOffset() ? $query->getOffset() : 0;
+ $limit = $query->hasLimit() ? $query->getLimit() : 0;
+ $data = $this->data;
+
+ if ($query->hasOrder()) {
+ uasort($data, [$query, 'compare']);
+ }
+
+ $foundStringKey = false;
+ $result = [];
+ $skipped = 0;
+ foreach ($data as $key => $row) {
+ if ($this->keyColumn !== null && !isset($row->{$this->keyColumn})) {
+ $row = clone $row; // Make sure that this won't affect the actual data
+ $row->{$this->keyColumn} = $key;
+ }
+
+ if (! $filter->matches($row)) {
+ continue;
+ } elseif ($skipped < $offset) {
+ $skipped++;
+ continue;
+ }
+
+ // Get only desired columns if asked so
+ if (! empty($columns)) {
+ $filteredRow = (object) array();
+ foreach ($columns as $alias => $name) {
+ if (! is_string($alias)) {
+ $alias = $name;
+ }
+
+ if (isset($row->$name)) {
+ $filteredRow->$alias = $row->$name;
+ } else {
+ $filteredRow->$alias = null;
+ }
+ }
+ } else {
+ $filteredRow = $row;
+ }
+
+ $foundStringKey |= is_string($key);
+ $result[$key] = $filteredRow;
+
+ if (count($result) === $limit) {
+ break;
+ }
+ }
+
+ if (! $foundStringKey) {
+ $result = array_values($result);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return whether a query result exists
+ *
+ * @return bool
+ */
+ protected function hasResult()
+ {
+ return $this->result !== null;
+ }
+
+ /**
+ * Set the current result
+ *
+ * @param array $result
+ *
+ * @return $this
+ */
+ protected function setResult(array $result)
+ {
+ $this->result = $result;
+ return $this;
+ }
+
+ /**
+ * Return the result for the given query
+ *
+ * @param SimpleQuery $query
+ *
+ * @return array
+ */
+ protected function getResult(SimpleQuery $query)
+ {
+ if (! $this->hasResult()) {
+ $this->setResult($this->createResult($query));
+ }
+
+ return $this->result;
+ }
+}
diff --git a/library/Icinga/Data/Db/DbConnection.php b/library/Icinga/Data/Db/DbConnection.php
new file mode 100644
index 0000000..fc6814d
--- /dev/null
+++ b/library/Icinga/Data/Db/DbConnection.php
@@ -0,0 +1,655 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Db;
+
+use DateTime;
+use DateTimeZone;
+use Exception;
+use Icinga\Data\Filter\FilterEqual;
+use Icinga\Data\Filter\FilterNotEqual;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use PDO;
+use Iterator;
+use Zend_Db;
+use Zend_Db_Expr;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Extensible;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterAnd;
+use Icinga\Data\Filter\FilterNot;
+use Icinga\Data\Filter\FilterOr;
+use Icinga\Data\Reducible;
+use Icinga\Data\ResourceFactory;
+use Icinga\Data\Selectable;
+use Icinga\Data\Updatable;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Encapsulate database connections and query creation
+ */
+class DbConnection implements Selectable, Extensible, Updatable, Reducible, Inspectable
+{
+ /**
+ * Connection config
+ *
+ * @var ConfigObject
+ */
+ private $config;
+
+ /**
+ * Database type
+ *
+ * @var string
+ */
+ private $dbType;
+
+ /**
+ * @var \Zend_Db_Adapter_Abstract
+ */
+ private $dbAdapter;
+
+ /**
+ * Table prefix
+ *
+ * @var string
+ */
+ private $tablePrefix = '';
+
+ private static $genericAdapterOptions = array(
+ Zend_Db::AUTO_QUOTE_IDENTIFIERS => false,
+ Zend_Db::CASE_FOLDING => Zend_Db::CASE_LOWER
+ );
+
+ private static $driverOptions = array(
+ PDO::ATTR_TIMEOUT => 10,
+ PDO::ATTR_CASE => PDO::CASE_LOWER,
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
+ );
+
+ /**
+ * Create a new connection object
+ *
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config = null)
+ {
+ $this->config = $config;
+ $this->connect();
+ }
+
+ /**
+ * Provide a query on this connection
+ *
+ * @return DbQuery
+ */
+ public function select()
+ {
+ return new DbQuery($this);
+ }
+
+ /**
+ * Fetch and return all rows of the given query's result set using an iterator
+ *
+ * @param DbQuery $query
+ *
+ * @return Iterator
+ */
+ public function query(DbQuery $query)
+ {
+ return $query->getSelectQuery()->query();
+ }
+
+ /**
+ * Get the connection configuration
+ *
+ * @return ConfigObject
+ */
+ public function getConfig()
+ {
+ return $this->config;
+ }
+
+ /**
+ * Getter for database type
+ *
+ * @return string
+ */
+ public function getDbType()
+ {
+ return $this->dbType;
+ }
+
+ /**
+ * Getter for the Zend_Db_Adapter
+ *
+ * @return \Zend_Db_Adapter_Abstract
+ */
+ public function getDbAdapter()
+ {
+ return $this->dbAdapter;
+ }
+
+ /**
+ * Create a new connection
+ */
+ private function connect()
+ {
+ $genericAdapterOptions = self::$genericAdapterOptions;
+ $driverOptions = self::$driverOptions;
+ $adapterParamaters = array(
+ 'host' => $this->config->host,
+ 'username' => $this->config->username,
+ 'password' => $this->config->password,
+ 'dbname' => $this->config->dbname,
+ 'charset' => $this->config->charset ?: null,
+ 'options' => & $genericAdapterOptions,
+ 'driver_options' => & $driverOptions
+ );
+ $this->dbType = strtolower($this->config->get('db', 'mysql'));
+ switch ($this->dbType) {
+ case 'mssql':
+ $adapter = 'Pdo_Mssql';
+ $pdoType = $this->config->get('pdoType');
+ if (empty($pdoType)) {
+ if (extension_loaded('sqlsrv')) {
+ $adapter = 'Sqlsrv';
+ } else {
+ $pdoType = 'dblib';
+ }
+ }
+ if ($pdoType === 'dblib') {
+ // Driver does not support setting attributes
+ unset($adapterParamaters['options']);
+ unset($adapterParamaters['driver_options']);
+ }
+ if (! empty($pdoType)) {
+ $adapterParamaters['pdoType'] = $pdoType;
+ }
+ $defaultPort = 1433;
+ break;
+ case 'mysql':
+ $adapter = 'Pdo_Mysql';
+ if ($this->config->use_ssl) {
+ # The presence of these keys as empty strings or null cause non-ssl connections to fail
+ if ($this->config->ssl_key) {
+ $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_KEY] = $this->config->ssl_key;
+ }
+ if ($this->config->ssl_cert) {
+ $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CERT] = $this->config->ssl_cert;
+ }
+ if ($this->config->ssl_ca) {
+ $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CA] = $this->config->ssl_ca;
+ }
+ if ($this->config->ssl_capath) {
+ $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CAPATH] = $this->config->ssl_capath;
+ }
+ if ($this->config->ssl_cipher) {
+ $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CIPHER] = $this->config->ssl_cipher;
+ }
+ if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')
+ && $this->config->ssl_do_not_verify_server_cert
+ ) {
+ $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false;
+ }
+ }
+ /*
+ * Set MySQL server SQL modes to behave as closely as possible to Oracle and PostgreSQL. Note that the
+ * ONLY_FULL_GROUP_BY mode is left on purpose because MySQL requires you to specify all non-aggregate
+ * columns in the group by list even if the query is grouped by the master table's primary key which is
+ * valid ANSI SQL though. Further in that case the query plan would suffer if you add more columns to
+ * the group by list.
+ */
+ $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] =
+ 'SET SESSION SQL_MODE=\'STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,'
+ . 'ANSI_QUOTES,PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION\'';
+ if (isset($adapterParamaters['charset'])) {
+ $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ', NAMES ' . $adapterParamaters['charset'];
+ if (trim($adapterParamaters['charset']) === 'latin1') {
+ // Required for MySQL 8+ because we need PIPES_AS_CONCAT and
+ // have several columns with explicit COLLATE instructions
+ $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ' COLLATE latin1_general_ci';
+ }
+
+ unset($adapterParamaters['charset']);
+ }
+
+ $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ", time_zone='" . $this->defaultTimezoneOffset() . "'";
+ $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .=';';
+ $defaultPort = 3306;
+ break;
+ case 'oci':
+ $adapter = 'Oracle';
+ unset($adapterParamaters['options']);
+ unset($adapterParamaters['driver_options']);
+ $adapterParamaters['driver_options'] = array(
+ 'lob_as_string' => true
+ );
+ $defaultPort = 1521;
+ break;
+ case 'oracle':
+ $adapter = 'Pdo_Oci';
+ $defaultPort = 1521;
+
+ // remove host parameter when not configured
+ if (empty($this->config->host)) {
+ unset($adapterParamaters['host']);
+ }
+ break;
+ case 'pgsql':
+ $adapter = 'Pdo_Pgsql';
+ $defaultPort = 5432;
+ break;
+ case 'ibm':
+ $adapter = 'Pdo_Ibm';
+ $defaultPort = 50000;
+ break;
+ case 'sqlite':
+ $adapter = 'Pdo_Sqlite';
+ $defaultPort = 0; // Dummy port because a value is required
+ break;
+ default:
+ throw new ConfigurationError(
+ 'Backend "%s" is not supported',
+ $this->dbType
+ );
+ }
+ $adapterParamaters['port'] = $this->config->get('port', $defaultPort);
+ $this->dbAdapter = Zend_Db::factory($adapter, $adapterParamaters);
+ $this->dbAdapter->setFetchMode(Zend_Db::FETCH_OBJ);
+ // TODO(el/tg): The profiler is disabled per default, why do we disable the profiler explicitly?
+ $this->dbAdapter->getProfiler()->setEnabled(false);
+ }
+
+ public static function fromResourceName($name)
+ {
+ return new static(ResourceFactory::getResourceConfig($name));
+ }
+
+ /**
+ * Getter for the table prefix
+ *
+ * @return string
+ */
+ public function getTablePrefix()
+ {
+ return $this->tablePrefix;
+ }
+
+ /**
+ * Setter for the table prefix
+ *
+ * @param string $prefix
+ *
+ * @return $this
+ */
+ public function setTablePrefix($prefix)
+ {
+ $this->tablePrefix = $prefix;
+ return $this;
+ }
+
+ /**
+ * Get offset from the current default timezone to GMT
+ *
+ * @return string
+ */
+ protected function defaultTimezoneOffset()
+ {
+ $tz = new DateTimeZone(date_default_timezone_get());
+ $offset = $tz->getOffset(new DateTime());
+ $prefix = $offset >= 0 ? '+' : '-';
+ $offset = abs($offset);
+ $hours = (int) floor($offset / 3600);
+ $minutes = (int) floor(($offset % 3600) / 60);
+ return sprintf('%s%d:%02d', $prefix, $hours, $minutes);
+ }
+
+ /**
+ * Count all rows of the result set
+ *
+ * @param DbQuery $query
+ *
+ * @return int
+ */
+ public function count(DbQuery $query)
+ {
+ return (int) $this->dbAdapter->fetchOne($query->getCountQuery());
+ }
+
+ /**
+ * Retrieve an array containing all rows of the result set
+ *
+ * @param DbQuery $query
+ *
+ * @return array
+ */
+ public function fetchAll(DbQuery $query)
+ {
+ return $this->dbAdapter->fetchAll($query->getSelectQuery());
+ }
+
+ /**
+ * Fetch the first row of the result set
+ *
+ * @param DbQuery $query
+ *
+ * @return mixed
+ */
+ public function fetchRow(DbQuery $query)
+ {
+ return $this->dbAdapter->fetchRow($query->getSelectQuery());
+ }
+
+ /**
+ * Fetch the first column of all rows of the result set as an array
+ *
+ * @param DbQuery $query
+ *
+ * @return array
+ */
+ public function fetchColumn(DbQuery $query)
+ {
+ return $this->dbAdapter->fetchCol($query->getSelectQuery());
+ }
+
+ /**
+ * Fetch the first column of the first row of the result set
+ *
+ * @param DbQuery $query
+ *
+ * @return string
+ */
+ public function fetchOne(DbQuery $query)
+ {
+ return $this->dbAdapter->fetchOne($query->getSelectQuery());
+ }
+
+ /**
+ * Fetch all rows of the result set as an array of key-value pairs
+ *
+ * The first column is the key, the second column is the value.
+ *
+ * @param DbQuery $query
+ *
+ * @return array
+ */
+ public function fetchPairs(DbQuery $query)
+ {
+ return $this->dbAdapter->fetchPairs($query->getSelectQuery());
+ }
+
+ /**
+ * Insert a table row with the given data
+ *
+ * Note that the base implementation does not perform any quoting on the $table argument.
+ * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value
+ * as third parameter $types to define a different type than string for a particular column.
+ *
+ * @param string $table
+ * @param array $bind
+ * @param array $types
+ *
+ * @return int The number of affected rows
+ */
+ public function insert($table, array $bind, array $types = array())
+ {
+ $columns = $values = array();
+ foreach ($bind as $column => $value) {
+ $columns[] = $column;
+ if ($value instanceof Zend_Db_Expr) {
+ $values[] = (string) $value;
+ unset($bind[$column]);
+ } else {
+ $values[] = ':' . $column;
+ }
+ }
+
+ $sql = 'INSERT INTO ' . $table
+ . ' (' . join(', ', $columns) . ') '
+ . 'VALUES (' . join(', ', $values) . ')';
+ $statement = $this->dbAdapter->prepare($sql);
+
+ foreach ($bind as $column => $value) {
+ $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR;
+ $statement->bindValue(':' . $column, $value, $type);
+ }
+
+ $statement->execute();
+ return $statement->rowCount();
+ }
+
+ /**
+ * Update table rows with the given data, optionally limited by using a filter
+ *
+ * Note that the base implementation does not perform any quoting on the $table argument.
+ * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value
+ * as fourth parameter $types to define a different type than string for a particular column.
+ *
+ * @param string $table
+ * @param array $bind
+ * @param Filter $filter
+ * @param array $types
+ *
+ * @return int The number of affected rows
+ */
+ public function update($table, array $bind, Filter $filter = null, array $types = array())
+ {
+ $set = array();
+ foreach ($bind as $column => $value) {
+ if ($value instanceof Zend_Db_Expr) {
+ $set[] = $column . ' = ' . $value;
+ unset($bind[$column]);
+ } else {
+ $set[] = $column . ' = :' . $column;
+ }
+ }
+
+ $sql = 'UPDATE ' . $table
+ . ' SET ' . join(', ', $set)
+ . ($filter ? ' WHERE ' . $this->renderFilter($filter) : '');
+ $statement = $this->dbAdapter->prepare($sql);
+
+ foreach ($bind as $column => $value) {
+ $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR;
+ $statement->bindValue(':' . $column, $value, $type);
+ }
+
+ $statement->execute();
+ return $statement->rowCount();
+ }
+
+ /**
+ * Delete table rows, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param Filter $filter
+ *
+ * @return int The number of affected rows
+ */
+ public function delete($table, Filter $filter = null)
+ {
+ return $this->dbAdapter->delete($table, $filter ? $this->renderFilter($filter) : '');
+ }
+
+ /**
+ * Render and return the given filter as SQL-WHERE clause
+ *
+ * @param Filter $filter
+ *
+ * @return string
+ */
+ public function renderFilter(Filter $filter, $level = 0)
+ {
+ // TODO: This is supposed to supersede DbQuery::renderFilter()
+ $where = '';
+ if ($filter->isChain()) {
+ if ($filter instanceof FilterAnd) {
+ $operator = ' AND ';
+ } elseif ($filter instanceof FilterOr) {
+ $operator = ' OR ';
+ } elseif ($filter instanceof FilterNot) {
+ $operator = ' AND ';
+ $where .= ' NOT ';
+ } else {
+ throw new ProgrammingError('Cannot render filter: %s', get_class($filter));
+ }
+
+ if (! $filter->isEmpty()) {
+ $parts = array();
+ foreach ($filter->filters() as $filterPart) {
+ $part = $this->renderFilter($filterPart, $level + 1);
+ if ($part) {
+ $parts[] = $part;
+ }
+ }
+
+ if (! empty($parts)) {
+ if ($level > 0) {
+ $where .= ' (' . implode($operator, $parts) . ') ';
+ } else {
+ $where .= implode($operator, $parts);
+ }
+ }
+ } else {
+ return ''; // Explicitly return the empty string due to the FilterNot case
+ }
+ } else {
+ $where .= $this->renderFilterExpression($filter);
+ }
+
+ return $where;
+ }
+
+ /**
+ * Render and return the given filter expression
+ *
+ * @param Filter $filter
+ *
+ * @return string
+ */
+ protected function renderFilterExpression(Filter $filter)
+ {
+ $column = $filter->getColumn();
+ $sign = $filter->getSign();
+ $value = $filter->getExpression();
+
+ if (is_array($value)) {
+ $comp = [];
+ $pattern = [];
+ foreach ($value as $val) {
+ if (strpos($val, '*') === false) {
+ $comp[] = $val;
+ } else {
+ $pattern[] = $this->renderFilterExpression(Filter::expression($column, $sign, $val));
+ }
+ }
+
+ $sql = $pattern;
+ if ($sign === '=') {
+ if (! empty($comp)) {
+ $sql[] = $column . ' IN (' . $this->dbAdapter->quote($comp) . ')';
+ }
+
+ $operator = 'OR';
+ } elseif ($sign === '!=') {
+ if (! empty($comp)) {
+ $sql[] = sprintf(
+ '(%1$s NOT IN (%2$s) OR %1$s IS NULL)',
+ $column,
+ $this->dbAdapter->quote($comp)
+ );
+ }
+
+ $operator = 'AND';
+ } else {
+ throw new ProgrammingError(
+ 'Unable to render array expressions with operators other than equal or not equal'
+ );
+ }
+
+ return count($sql) === 1 ? $sql[0] : '(' . implode(" $operator ", $sql) . ')';
+ } elseif ($sign === '='
+ && ! $filter instanceof FilterEqual
+ && $value !== null
+ && strpos($value, '*') !== false
+ ) {
+ if ($value === '*') {
+ return $column . ' IS NOT NULL';
+ }
+
+ return $column . ' LIKE ' . $this->dbAdapter->quote(preg_replace('~\*~', '%', $value));
+ } elseif ($sign === '!='
+ && ! $filter instanceof FilterNotEqual
+ && $value !== null
+ && strpos($value, '*') !== false
+ ) {
+ if ($value === '*') {
+ return $column . ' IS NULL';
+ }
+
+ return sprintf(
+ '(%1$s NOT LIKE %2$s OR %1$s IS NULL)',
+ $column,
+ $this->dbAdapter->quote(preg_replace('~\*~', '%', $value))
+ );
+ } elseif ($sign === '!=') {
+ return sprintf('(%1$s != %2$s OR %1$s IS NULL)', $column, $this->dbAdapter->quote($value));
+ } else {
+ return sprintf('%s %s %s', $column, $sign, $this->dbAdapter->quote($value));
+ }
+ }
+
+ public function inspect()
+ {
+ $insp = new Inspection('Db Connection');
+ try {
+ $this->getDbAdapter()->getConnection();
+ $config = $this->dbAdapter->getConfig();
+ $insp->write(sprintf(
+ 'Connection to %s as %s on %s:%s successful',
+ $config['dbname'],
+ $config['username'],
+ array_key_exists('host', $config) ? $config['host'] : '(none)',
+ $config['port']
+ ));
+ switch ($this->dbType) {
+ case 'mysql':
+ $rows = $this->dbAdapter->query(
+ 'SHOW VARIABLES WHERE variable_name ' .
+ 'IN (\'version\', \'protocol_version\', \'version_compile_os\', \'have_ssl\');'
+ )->fetchAll();
+ $sqlinsp = new Inspection('MySQL');
+ $hasSsl = false;
+ foreach ($rows as $row) {
+ $sqlinsp->write($row->variable_name . ': ' . $row->value);
+ if ($row->variable_name === 'have_ssl' && $row->value === 'YES') {
+ $hasSsl = true;
+ }
+ }
+ if ($hasSsl) {
+ $ssl_rows = $this->dbAdapter->query(
+ 'SHOW STATUS WHERE variable_name ' .
+ 'IN (\'Ssl_Cipher\');'
+ )->fetchAll();
+ foreach ($ssl_rows as $ssl_row) {
+ $sqlinsp->write($ssl_row->variable_name . ': ' . $ssl_row->value);
+ }
+ }
+ $insp->write($sqlinsp);
+ break;
+ case 'pgsql':
+ $row = $this->dbAdapter->query('SELECT version();')->fetchAll();
+ $sqlinsp = new Inspection('PostgreSQL');
+ $sqlinsp->write($row[0]->version);
+ $insp->write($sqlinsp);
+ break;
+ }
+ } catch (Exception $e) {
+ return $insp->error(sprintf('Connection failed %s', $e->getMessage()));
+ }
+ return $insp;
+ }
+}
diff --git a/library/Icinga/Data/Db/DbQuery.php b/library/Icinga/Data/Db/DbQuery.php
new file mode 100644
index 0000000..ff1d131
--- /dev/null
+++ b/library/Icinga/Data/Db/DbQuery.php
@@ -0,0 +1,565 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Db;
+
+use DateInterval;
+use DateTime;
+use DateTimeZone;
+use Exception;
+use Icinga\Data\Filter\Filter;
+use Zend_Db_Adapter_Abstract;
+use Zend_Db_Expr;
+use Zend_Db_Select;
+use Icinga\Application\Logger;
+use Icinga\Data\SimpleQuery;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\QueryException;
+
+/**
+ * Database query class
+ */
+class DbQuery extends SimpleQuery
+{
+ /**
+ * @var Zend_Db_Adapter_Abstract
+ */
+ protected $db;
+
+ /**
+ * Whether or not the query is a sub query
+ *
+ * Sub queries are automatically wrapped in parentheses
+ *
+ * @var bool
+ */
+ protected $isSubQuery = false;
+
+ /**
+ * Select query
+ *
+ * @var Zend_Db_Select
+ */
+ protected $select;
+
+ /**
+ * Whether to use a subquery for counting
+ *
+ * When the query is distinct or has a HAVING or GROUP BY clause this must be set to true
+ *
+ * @var bool
+ */
+ protected $useSubqueryCount = false;
+
+ /**
+ * Count query result
+ *
+ * Count queries are only executed once
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * GROUP BY clauses
+ *
+ * @var string|array
+ */
+ protected $group;
+
+ protected function init()
+ {
+ $this->db = $this->ds->getDbAdapter();
+ $this->select = $this->db->select();
+ parent::init();
+ }
+
+ /**
+ * Get whether or not the query is a sub query
+ */
+ public function getIsSubQuery()
+ {
+ return $this->isSubQuery;
+ }
+
+ /**
+ * Set whether or not the query is a sub query
+ *
+ * @param bool $isSubQuery
+ *
+ * @return $this
+ */
+ public function setIsSubQuery($isSubQuery = true)
+ {
+ $this->isSubQuery = (bool) $isSubQuery;
+ return $this;
+ }
+
+ public function setUseSubqueryCount($useSubqueryCount = true)
+ {
+ $this->useSubqueryCount = $useSubqueryCount;
+ return $this;
+ }
+
+ public function from($target, array $fields = null)
+ {
+ parent::from($target, $fields);
+ $this->select->from($this->target, array());
+ return $this;
+ }
+
+ public function where($condition, $value = null)
+ {
+ // $this->count = $this->select = null;
+ return parent::where($condition, $value);
+ }
+
+ public function addFilter(Filter $filter)
+ {
+ $this->expressionsToTimestamp($filter);
+ return parent::addFilter($filter);
+ }
+
+ private function expressionsToTimestamp(Filter $filter)
+ {
+ if ($filter->isChain()) {
+ foreach ($filter->filters() as $child) {
+ $this->expressionsToTimestamp($child);
+ }
+ } elseif ($this->isTimestamp($filter->getColumn())) {
+ $filter->setExpression($this->valueToTimestamp($filter->getExpression()));
+ }
+ }
+
+ protected function dbSelect()
+ {
+ return clone $this->select;
+ }
+
+ /**
+ * Return the underlying select
+ *
+ * @return Zend_Db_Select
+ */
+ public function select()
+ {
+ return $this->select;
+ }
+
+ /**
+ * Get the select query
+ *
+ * Applies order and limit if any
+ *
+ * @return Zend_Db_Select
+ */
+ public function getSelectQuery()
+ {
+ $select = $this->dbSelect();
+ // Add order fields to select for postgres distinct queries (#6351)
+ if ($this->hasOrder()
+ && $this->getDatasource()->getDbType() === 'pgsql'
+ && $select->getPart(Zend_Db_Select::DISTINCT) === true) {
+ foreach ($this->getOrder() as $fieldAndDirection) {
+ if (array_search($fieldAndDirection[0], $this->columns, true) === false) {
+ $this->columns[] = $fieldAndDirection[0];
+ }
+ }
+ }
+
+ $group = $this->getGroup();
+ if ($group) {
+ $select->group($group);
+ }
+
+ if (! empty($this->columns)) {
+ $select->columns($this->columns);
+ }
+
+ $this->applyFilterSql($select);
+
+ if ($this->hasLimit() || $this->hasOffset()) {
+ $select->limit($this->getLimit(), $this->getOffset());
+ }
+ if ($this->hasOrder()) {
+ foreach ($this->getOrder() as $fieldAndDirection) {
+ $select->order(
+ $fieldAndDirection[0] . ' ' . $fieldAndDirection[1]
+ );
+ }
+ }
+
+ return $select;
+ }
+
+ protected function applyFilterSql($select)
+ {
+ $where = $this->getDatasource()->renderFilter($this->filter);
+ if ($where !== '') {
+ $select->where($where);
+ }
+ }
+
+ protected function escapeForSql($value)
+ {
+ // bindParam? bindValue?
+ if (is_array($value)) {
+ $ret = array();
+ foreach ($value as $val) {
+ $ret[] = $this->escapeForSql($val);
+ }
+ return implode(', ', $ret);
+ } else {
+ //if (preg_match('/^\d+$/', $value)) {
+ // return $value;
+ //} else {
+ return $this->db->quote($value);
+ //}
+ }
+ }
+
+ protected function escapeWildcards($value)
+ {
+ return preg_replace('/\*/', '%', $value);
+ }
+
+ protected function valueToTimestamp($value)
+ {
+ if (is_string($value)) {
+ if (ctype_digit($value)) {
+ $value = (int) $value;
+ } else {
+ $value = strtotime($value);
+ }
+ } elseif (! is_int($value)) {
+ $value = (int) $value;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Render the given timestamp based on the local timezone
+ *
+ * Since {@see DbConnection::defaultTimezoneOffset()} tells the database the timezone with just an offset,
+ * this will prepare the rendered value in a way that it plays fine with daylight savings.
+ *
+ * @param int $value
+ * @return string
+ */
+ protected function timestampForSql($value)
+ {
+ if ($this->getDatasource()->getDbType() === 'pgsql') {
+ // We don't tell PostgreSQL the user's timezone
+ $dateTime = (new DateTime())
+ ->setTimezone(new DateTimeZone('UTC'))
+ ->setTimestamp($value);
+ } else {
+ $dateTime = new DateTime();
+ // Get "current" offset the database will use
+ $offsetToUTC = $dateTime->getOffset();
+ // Set timezone to UTC and initialize it with the timestamp
+ $dateTime->setTimezone(new DateTimeZone('UTC'))->setTimestamp($value);
+ // Normalize every datetime based on the only offset the database knows about
+ if ($offsetToUTC >= 0) {
+ $dateTime->add(new DateInterval("PT{$offsetToUTC}S"));
+ } else {
+ $offsetToUTC = abs($offsetToUTC);
+ $dateTime->sub(new DateInterval("PT{$offsetToUTC}S"));
+ }
+ }
+
+ return $dateTime->format('Y-m-d H:i:s');
+ }
+
+ /**
+ * Check for timestamp fields
+ *
+ * TODO: This is not here to do automagic timestamp stuff. One may
+ * override this function for custom voodoo, IdoQuery right now
+ * does. IMO we need to split whereToSql functionality, however
+ * I'd prefer to wait with this unless we understood how other
+ * backends will work. We probably should also rename this
+ * function to isTimestampColumn().
+ *
+ * @param string $field Field Field name to checked
+ * @return bool Whether this field expects timestamps
+ */
+ public function isTimestamp($field)
+ {
+ return false;
+ }
+
+ /**
+ * Get the count query
+ *
+ * @return Zend_Db_Select
+ */
+ public function getCountQuery()
+ {
+ // TODO: there may be situations where we should clone the "select"
+ $count = $this->dbSelect();
+ $this->applyFilterSql($count);
+ $group = $this->getGroup();
+ if ($this->useSubqueryCount || $group) {
+ if (! empty($this->columns)) {
+ $count->columns($this->columns);
+ }
+ if ($group) {
+ $count->group($group);
+ }
+ $columns = array('cnt' => 'COUNT(*)');
+ return $this->db->select()->from($count, $columns);
+ }
+
+ $count->columns(array('cnt' => 'COUNT(*)'));
+ return $count;
+ }
+
+ /**
+ * Count all rows of the result set
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ if ($this->count === null) {
+ $this->count = parent::count();
+ }
+
+ return $this->count;
+ }
+
+ /**
+ * Return the select and count query as a textual representation
+ *
+ * @return string A string containing the select and count query, using unix style newlines as linebreaks
+ */
+ public function dump()
+ {
+ return "QUERY\n=====\n"
+ . $this->getSelectQuery()
+ . "\n\nCOUNT\n=====\n"
+ . $this->getCountQuery()
+ . "\n\n";
+ }
+
+ public function __clone()
+ {
+ parent::__clone();
+ $this->select = clone $this->select;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ $select = (string) $this->getSelectQuery();
+ return $this->getIsSubQuery() ? ('(' . $select . ')') : $select;
+ } catch (Exception $e) {
+ Logger::debug('Failed to render DbQuery. An error occured: %s', $e);
+ return '';
+ }
+ }
+
+ /**
+ * Add a GROUP BY clause
+ *
+ * @param string|array $group
+ *
+ * @return $this
+ */
+ public function group($group)
+ {
+ $this->group = $group;
+ return $this;
+ }
+
+ /**
+ * Return the GROUP BY clause
+ *
+ * @return string|array
+ */
+ public function getGroup()
+ {
+ return $this->group;
+ }
+
+ /**
+ * Return whether the given table has been joined
+ *
+ * @param string $table
+ *
+ * @return bool
+ */
+ public function hasJoinedTable($table)
+ {
+ $fromPart = $this->select->getPart(Zend_Db_Select::FROM);
+ if (isset($fromPart[$table])) {
+ return true;
+ }
+
+ foreach ($fromPart as $options) {
+ if ($options['tableName'] === $table && $options['joinType'] !== Zend_Db_Select::FROM) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return the alias used for joining the given table
+ *
+ * @param string $table
+ *
+ * @return string|null null in case no alias is being used
+ *
+ * @throws ProgrammingError In case the given table has not been joined
+ */
+ public function getJoinedTableAlias($table)
+ {
+ $fromPart = $this->select->getPart(Zend_Db_Select::FROM);
+ if (isset($fromPart[$table])) {
+ if ($fromPart[$table]['joinType'] === Zend_Db_Select::FROM) {
+ throw new ProgrammingError('Table "%s" has not been joined', $table);
+ }
+
+ return; // No alias in use
+ }
+
+ foreach ($fromPart as $alias => $options) {
+ if ($options['tableName'] === $table && $options['joinType'] !== Zend_Db_Select::FROM) {
+ return $alias;
+ }
+ }
+
+ throw new ProgrammingError('Table "%s" has not been joined', $table);
+ }
+
+ /**
+ * Add an INNER JOIN table and colums to the query
+ *
+ * @param array|string|Zend_Db_Expr $name The table name
+ * @param string $cond Join on this condition
+ * @param array|string $cols The columns to select from the joined table
+ * @param string $schema The database name to specify, if any
+ *
+ * @return $this
+ */
+ public function join($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null)
+ {
+ $this->select->joinInner($name, $cond, $cols, $schema);
+ return $this;
+ }
+
+ /**
+ * Add an INNER JOIN table and colums to the query
+ *
+ * @param array|string|Zend_Db_Expr $name The table name
+ * @param string $cond Join on this condition
+ * @param array|string $cols The columns to select from the joined table
+ * @param string $schema The database name to specify, if any
+ *
+ * @return $this
+ */
+ public function joinInner($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null)
+ {
+ $this->select->joinInner($name, $cond, $cols, $schema);
+ return $this;
+ }
+
+ /**
+ * Add a LEFT OUTER JOIN table and colums to the query
+ *
+ * @param array|string|Zend_Db_Expr $name The table name
+ * @param string $cond Join on this condition
+ * @param array|string $cols The columns to select from the joined table
+ * @param string $schema The database name to specify, if any
+ *
+ * @return $this
+ */
+ public function joinLeft($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null)
+ {
+ $this->select->joinLeft($name, $cond, $cols, $schema);
+ return $this;
+ }
+
+ /**
+ * Add a RIGHT OUTER JOIN table and colums to the query
+ *
+ * @param array|string|Zend_Db_Expr $name The table name
+ * @param string $cond Join on this condition
+ * @param array|string $cols The columns to select from the joined table
+ * @param string $schema The database name to specify, if any
+ *
+ * @return $this
+ */
+ public function joinRight($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null)
+ {
+ $this->select->joinRight($name, $cond, $cols, $schema);
+ return $this;
+ }
+
+ /**
+ * Add a FULL OUTER JOIN table and colums to the query
+ *
+ * @param array|string|Zend_Db_Expr $name The table name
+ * @param string $cond Join on this condition
+ * @param array|string $cols The columns to select from the joined table
+ * @param string $schema The database name to specify, if any
+ *
+ * @return $this
+ */
+ public function joinFull($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null)
+ {
+ $this->select->joinFull($name, $cond, $cols, $schema);
+ return $this;
+ }
+
+ /**
+ * Add a CROSS JOIN table and colums to the query
+ *
+ * @param array|string|Zend_Db_Expr $name The table name
+ * @param array|string $cols The columns to select from the joined table
+ * @param string $schema The database name to specify, if any
+ *
+ * @return $this
+ */
+ public function joinCross($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null)
+ {
+ $this->select->joinCross($name, $cols, $schema);
+ return $this;
+ }
+
+ /**
+ * Add a NATURAL JOIN table and colums to the query
+ *
+ * @param array|string|Zend_Db_Expr $name The table name
+ * @param array|string $cols The columns to select from the joined table
+ * @param string $schema The database name to specify, if any
+ *
+ * @return $this
+ */
+ public function joinNatural($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null)
+ {
+ $this->select->joinNatural($name, $cols, $schema);
+ return $this;
+ }
+
+ /**
+ * Add a UNION clause to the query
+ *
+ * @param array $select Select clauses for the union
+ * @param string $type Type of UNION to use
+ *
+ * @return $this
+ */
+ public function union($select = array(), $type = Zend_Db_Select::SQL_UNION)
+ {
+ $this->select->union($select, $type);
+ return $this;
+ }
+}
diff --git a/library/Icinga/Data/Extensible.php b/library/Icinga/Data/Extensible.php
new file mode 100644
index 0000000..ad690d8
--- /dev/null
+++ b/library/Icinga/Data/Extensible.php
@@ -0,0 +1,22 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Icinga\Exception\StatementException;
+
+/**
+ * Interface for data insertion
+ */
+interface Extensible
+{
+ /**
+ * Insert the given data for the given target
+ *
+ * @param string $target
+ * @param array $data
+ *
+ * @throws StatementException
+ */
+ public function insert($target, array $data);
+}
diff --git a/library/Icinga/Data/Fetchable.php b/library/Icinga/Data/Fetchable.php
new file mode 100644
index 0000000..342740a
--- /dev/null
+++ b/library/Icinga/Data/Fetchable.php
@@ -0,0 +1,47 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+/**
+ * Interface for retrieving data
+ */
+interface Fetchable
+{
+ /**
+ * Retrieve an array containing all rows of the result set
+ *
+ * @return array
+ */
+ public function fetchAll();
+
+ /**
+ * Fetch the first row of the result set
+ *
+ * @return mixed
+ */
+ public function fetchRow();
+
+ /**
+ * Fetch the first column of all rows of the result set as an array
+ *
+ * @return array
+ */
+ public function fetchColumn();
+
+ /**
+ * Fetch the first column of the first row of the result set
+ *
+ * @return string
+ */
+ public function fetchOne();
+
+ /**
+ * Fetch all rows of the result set as an array of key-value pairs
+ *
+ * The first column is the key, the second column is the value.
+ *
+ * @return array
+ */
+ public function fetchPairs();
+}
diff --git a/library/Icinga/Data/Filter/Filter.php b/library/Icinga/Data/Filter/Filter.php
new file mode 100644
index 0000000..f5d8bdf
--- /dev/null
+++ b/library/Icinga/Data/Filter/Filter.php
@@ -0,0 +1,255 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+use Icinga\Web\UrlParams;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Filter
+ *
+ * Base class for filters (why?) and factory for the different FilterOperators
+ */
+abstract class Filter
+{
+ protected $id = '1';
+
+ public function setId($id)
+ {
+ $this->id = (string) $id;
+ return $this;
+ }
+
+ abstract public function isExpression();
+
+ abstract public function isChain();
+
+ abstract public function isEmpty();
+
+ abstract public function toQueryString();
+
+ abstract public function andFilter(Filter $filter);
+
+ abstract public function orFilter(Filter $filter);
+
+ /**
+ * Whether the give row matches this Filter
+ *
+ * @param mixed $row Preferrably an stdClass instance
+ * @return bool
+ */
+ abstract public function matches($row);
+
+ public function getUrlParams()
+ {
+ return UrlParams::fromQueryString($this->toQueryString());
+ }
+
+ public function getById($id)
+ {
+ if ((string) $id === $this->getId()) {
+ return $this;
+ }
+ throw new ProgrammingError(
+ 'Trying to get invalid filter index "%s" from "%s" ("%s")',
+ $id,
+ $this,
+ $this->id
+ );
+ }
+
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ public function isRootNode()
+ {
+ return false === strpos($this->id, '-');
+ }
+
+ abstract public function listFilteredColumns();
+
+ public function applyChanges($changes)
+ {
+ $filter = $this;
+ $pairs = array();
+ foreach ($changes as $k => $v) {
+ if (preg_match('/^(column|value|sign|operator)_([\d-]+)$/', $k, $m)) {
+ $pairs[$m[2]][$m[1]] = $v;
+ }
+ }
+ $operators = array();
+ foreach ($pairs as $id => $fs) {
+ if (array_key_exists('operator', $fs)) {
+ $operators[$id] = $fs['operator'];
+ } else {
+ $f = $filter->getById($id);
+ $f->setColumn($fs['column']);
+ if ($f->getSign() !== $fs['sign']) {
+ if ($f->isRootNode()) {
+ $filter = $f->setSign($fs['sign']);
+ } else {
+ $filter->replaceById($id, $f->setSign($fs['sign']));
+ }
+ }
+ $f->setExpression($fs['value']);
+ }
+ }
+
+ krsort($operators, SORT_NATURAL);
+ foreach ($operators as $id => $operator) {
+ $f = $filter->getById($id);
+ if ($f->getOperatorName() !== $operator) {
+ if ($f->isRootNode()) {
+ $filter = $f->setOperatorName($operator);
+ } else {
+ $filter->replaceById($id, $f->setOperatorName($operator));
+ }
+ }
+ }
+
+ return $filter;
+ }
+
+ public function getParentId()
+ {
+ if ($this->isRootNode()) {
+ throw new ProgrammingError('Filter root nodes have no parent');
+ }
+ return substr($this->id, 0, strrpos($this->id, '-'));
+ }
+
+ public function getParent()
+ {
+ return $this->getById($this->getParentId());
+ }
+
+ public function hasId($id)
+ {
+ if ($id === $this->getId()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Where Filter factory
+ *
+ * @param string $col Column to be filtered
+ * @param string $filter Filter expression
+ *
+ * @throws FilterException
+ * @return FilterExpression
+ */
+ public static function where($col, $filter)
+ {
+ return new FilterExpression($col, '=', $filter);
+ }
+
+ public static function expression($col, $op, $expression)
+ {
+ switch ($op) {
+ case '=':
+ return new FilterMatch($col, $op, $expression);
+ case '<':
+ return new FilterLessThan($col, $op, $expression);
+ case '>':
+ return new FilterGreaterThan($col, $op, $expression);
+ case '>=':
+ return new FilterEqualOrGreaterThan($col, $op, $expression);
+ case '<=':
+ return new FilterEqualOrLessThan($col, $op, $expression);
+ case '!=':
+ return new FilterMatchNot($col, $op, $expression);
+ default:
+ throw new ProgrammingError(
+ 'There is no such filter sign: %s',
+ $op
+ );
+ }
+ }
+
+ /**
+ * Or FilterOperator factory
+ *
+ * @param Filter $filter,... Unlimited optional list of Filters
+ *
+ * @return FilterOr
+ */
+ public static function matchAny()
+ {
+ $args = func_get_args();
+ if (count($args) === 1 && is_array($args[0])) {
+ $args = $args[0];
+ }
+ return new FilterOr($args);
+ }
+
+ /**
+ * Or FilterOperator factory
+ *
+ * @param Filter $filter,... Unlimited optional list of Filters
+ *
+ * @return FilterAnd
+ */
+ public static function matchAll()
+ {
+ $args = func_get_args();
+ if (count($args) === 1 && is_array($args[0])) {
+ $args = $args[0];
+ }
+ return new FilterAnd($args);
+ }
+
+ /**
+ * FilterNot factory, negates the given filter
+ *
+ * @param Filter $filter Filter to be negated
+ *
+ * @return FilterNot
+ */
+ public static function not()
+ {
+ $args = func_get_args();
+ if (count($args) === 1) {
+ if (is_array($args[0])) {
+ $args = $args[0];
+ }
+ }
+ if (count($args) > 1) {
+ return new FilterNot(array(new FilterAnd($args)));
+ } else {
+ return new FilterNot($args);
+ }
+ }
+
+ public static function chain($operator, $filters = array())
+ {
+ switch ($operator) {
+ case 'AND':
+ return self::matchAll($filters);
+ case 'OR':
+ return self::matchAny($filters);
+ case 'NOT':
+ return self::not($filters);
+ }
+ throw new ProgrammingError(
+ '"%s" is not a valid filter chain operator',
+ $operator
+ );
+ }
+
+ /**
+ * Create filter from queryString
+ *
+ * This is still pretty basic, need improvement
+ *
+ * @return static
+ */
+ public static function fromQueryString($query)
+ {
+ return FilterQueryString::parse($query);
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterAnd.php b/library/Icinga/Data/Filter/FilterAnd.php
new file mode 100644
index 0000000..96b68cc
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterAnd.php
@@ -0,0 +1,42 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+/**
+ * Filter list AND
+ *
+ * Binary AND, all contained filters must succeed
+ */
+class FilterAnd extends FilterChain
+{
+ protected $operatorName = 'AND';
+
+ protected $operatorSymbol = '&';
+
+ /**
+ * Whether the given row object matches this filter
+ *
+ * @object $row
+ * @return boolean
+ */
+ public function matches($row)
+ {
+ foreach ($this->filters as $filter) {
+ if (! $filter->matches($row)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public function andFilter(Filter $filter)
+ {
+ return $this->addFilter($filter);
+ }
+
+ public function orFilter(Filter $filter)
+ {
+ return Filter::matchAny($this, $filter);
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterChain.php b/library/Icinga/Data/Filter/FilterChain.php
new file mode 100644
index 0000000..0f1e071
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterChain.php
@@ -0,0 +1,286 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\QueryException;
+
+/**
+ * FilterChain
+ *
+ * A FilterChain contains a list ...
+ */
+abstract class FilterChain extends Filter
+{
+ protected $filters = array();
+
+ protected $operatorName;
+
+ protected $operatorSymbol;
+
+ protected $allowedColumns;
+
+ /**
+ * Set the filters
+ *
+ * @param array $filters
+ *
+ * @return $this
+ */
+ public function setFilters(array $filters)
+ {
+ $this->filters = $filters;
+
+ $this->refreshChildIds();
+
+ return $this;
+ }
+
+ public function hasId($id)
+ {
+ foreach ($this->filters() as $filter) {
+ if ($filter->hasId($id)) {
+ return true;
+ }
+ }
+ return parent::hasId($id);
+ }
+
+ public function getById($id)
+ {
+ foreach ($this->filters() as $filter) {
+ if ($filter->hasId($id)) {
+ return $filter->getById($id);
+ }
+ }
+ return parent::getById($id);
+ }
+
+ public function removeId($id)
+ {
+ if ($id === $this->getId()) {
+ $this->filters = array();
+ return $this;
+ }
+ $remove = null;
+ foreach ($this->filters as $key => $filter) {
+ if ($filter->getId() === $id) {
+ $remove = $key;
+ } elseif ($filter instanceof FilterChain) {
+ $filter->removeId($id);
+ }
+ }
+ if ($remove !== null) {
+ unset($this->filters[$remove]);
+ $this->filters = array_values($this->filters);
+ }
+ $this->refreshChildIds();
+ return $this;
+ }
+
+ public function replaceById($id, $filter)
+ {
+ $found = false;
+ foreach ($this->filters as $k => $child) {
+ if ($child->getId() == $id) {
+ $this->filters[$k] = $filter;
+ $found = true;
+ break;
+ }
+ if ($child->hasId($id)) {
+ $child->replaceById($id, $filter);
+ $found = true;
+ break;
+ }
+ }
+ if (! $found) {
+ throw new ProgrammingError('You tried to replace an unexistant child filter');
+ }
+ $this->refreshChildIds();
+ return $this;
+ }
+
+ protected function refreshChildIds()
+ {
+ $i = 0;
+ $id = $this->getId();
+ foreach ($this->filters as $filter) {
+ $i++;
+ $filter->setId($id . '-' . $i);
+ }
+ return $this;
+ }
+
+ public function setId($id)
+ {
+ return parent::setId($id)->refreshChildIds();
+ }
+
+ public function getOperatorName()
+ {
+ return $this->operatorName;
+ }
+
+ public function setOperatorName($name)
+ {
+ if ($name !== $this->operatorName) {
+ return Filter::chain($name, $this->filters);
+ }
+ return $this;
+ }
+
+ public function getOperatorSymbol()
+ {
+ return $this->operatorSymbol;
+ }
+
+ public function setAllowedFilterColumns(array $columns)
+ {
+ $this->allowedColumns = $columns;
+ return $this;
+ }
+
+ /**
+ * List and return all column names referenced in this filter
+ *
+ * @param array $columns The columns listed so far
+ *
+ * @return array
+ */
+ public function listFilteredColumns(array $columns = array())
+ {
+ foreach ($this->filters as $filter) {
+ if ($filter instanceof FilterExpression) {
+ $column= $filter->getColumn();
+ if (! in_array($column, $columns, true)) {
+ $columns[] = $column;
+ }
+ } else {
+ $columns = $filter->listFilteredColumns($columns);
+ }
+ }
+
+ return $columns;
+ }
+
+ public function toQueryString()
+ {
+ $parts = array();
+ if (empty($this->filters)) {
+ return '';
+ }
+ foreach ($this->filters() as $filter) {
+ if (! $filter->isEmpty()) {
+ $parts[] = $filter->toQueryString();
+ }
+ }
+
+ // TODO: getLevel??
+ if (strpos($this->getId(), '-')) {
+ return '(' . implode($this->getOperatorSymbol(), $parts) . ')';
+ } else {
+ return implode($this->getOperatorSymbol(), $parts);
+ }
+ }
+
+ /**
+ * Get simple string representation
+ *
+ * Useful for debugging only
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ if (empty($this->filters)) {
+ return '';
+ }
+ $parts = array();
+ foreach ($this->filters as $filter) {
+ if ($filter instanceof FilterChain) {
+ $parts[] = '(' . $filter . ')';
+ } else {
+ $parts[] = (string) $filter;
+ }
+ }
+ $op = ' ' . $this->getOperatorSymbol() . ' ';
+ return implode($op, $parts);
+ }
+
+ public function __construct($filters = array())
+ {
+ foreach ($filters as $filter) {
+ $this->addFilter($filter);
+ }
+ }
+
+ public function isExpression()
+ {
+ return false;
+ }
+
+ public function isChain()
+ {
+ return true;
+ }
+
+ public function isEmpty()
+ {
+ return empty($this->filters);
+ }
+
+ public function addFilter(Filter $filter)
+ {
+ if (! empty($this->allowedColumns)) {
+ $this->validateFilterColumns($filter);
+ }
+
+ $this->filters[] = $filter;
+ $filter->setId($this->getId() . '-' . $this->count());
+ return $this;
+ }
+
+ protected function validateFilterColumns(Filter $filter)
+ {
+ if ($filter->isExpression()) {
+ $valid = false;
+ foreach ($this->allowedColumns as $column) {
+ if (is_callable($column)) {
+ if (call_user_func($column, $filter->getColumn())) {
+ $valid = true;
+ break;
+ }
+ } elseif ($filter->getColumn() === $column) {
+ $valid = true;
+ break;
+ }
+ }
+
+ if (! $valid) {
+ throw new QueryException('Invalid filter column provided: %s', $filter->getColumn());
+ }
+ } else {
+ foreach ($filter->filters() as $subFilter) {
+ $this->validateFilterColumns($subFilter);
+ }
+ }
+ }
+
+ public function &filters()
+ {
+ return $this->filters;
+ }
+
+ public function count()
+ {
+ return count($this->filters);
+ }
+
+ public function __clone()
+ {
+ foreach ($this->filters as & $filter) {
+ $filter = clone $filter;
+ }
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterEqual.php b/library/Icinga/Data/Filter/FilterEqual.php
new file mode 100644
index 0000000..da53d3f
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterEqual.php
@@ -0,0 +1,16 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterEqual extends FilterExpression
+{
+ public function matches($row)
+ {
+ if (! isset($row->{$this->column})) {
+ return false;
+ }
+
+ return (string) $row->{$this->column} === (string) $this->expression;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php b/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php
new file mode 100644
index 0000000..d7bd5b8
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php
@@ -0,0 +1,16 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterEqualOrGreaterThan extends FilterExpression
+{
+ public function matches($row)
+ {
+ if (! isset($row->{$this->column})) {
+ return false;
+ }
+
+ return (string) $row->{$this->column} >= (string) $this->expression;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterEqualOrLessThan.php b/library/Icinga/Data/Filter/FilterEqualOrLessThan.php
new file mode 100644
index 0000000..8016fc4
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterEqualOrLessThan.php
@@ -0,0 +1,26 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterEqualOrLessThan extends FilterExpression
+{
+ public function __toString()
+ {
+ return $this->column . ' <= ' . $this->expression;
+ }
+
+ public function toQueryString()
+ {
+ return $this->column . '<=' . $this->expression;
+ }
+
+ public function matches($row)
+ {
+ if (! isset($row->{$this->column})) {
+ return false;
+ }
+
+ return (string) $row->{$this->column} <= (string) $this->expression;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterException.php b/library/Icinga/Data/Filter/FilterException.php
new file mode 100644
index 0000000..842d7ab
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterException.php
@@ -0,0 +1,15 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Filter Exception Class
+ *
+ * Filter Exceptions should be thrown on filter parse errors or similar
+ */
+class FilterException extends IcingaException
+{
+}
diff --git a/library/Icinga/Data/Filter/FilterExpression.php b/library/Icinga/Data/Filter/FilterExpression.php
new file mode 100644
index 0000000..73fb625
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterExpression.php
@@ -0,0 +1,224 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+use Exception;
+
+class FilterExpression extends Filter
+{
+ protected $column;
+ protected $sign;
+ protected $expression;
+
+ /**
+ * Does this filter compare case sensitive?
+ *
+ * @var bool
+ */
+ protected $caseSensitive;
+
+ public function __construct($column, $sign, $expression)
+ {
+ $column = trim($column);
+ $this->column = $column;
+ $this->sign = $sign;
+ $this->expression = $expression;
+ $this->caseSensitive = true;
+ }
+
+ public function isExpression()
+ {
+ return true;
+ }
+
+ public function isChain()
+ {
+ return false;
+ }
+
+ public function isEmpty()
+ {
+ return false;
+ }
+
+ public function getColumn()
+ {
+ return $this->column;
+ }
+
+ public function getSign()
+ {
+ return $this->sign;
+ }
+
+ public function setColumn($column)
+ {
+ $this->column = $column;
+ return $this;
+ }
+
+ public function getExpression()
+ {
+ return $this->expression;
+ }
+
+ /**
+ * Return whether this filter compares case sensitive
+ *
+ * @return bool
+ */
+ public function getCaseSensitive()
+ {
+ return $this->caseSensitive;
+ }
+
+ public function setExpression($expression)
+ {
+ $this->expression = $expression;
+ return $this;
+ }
+
+ public function setSign($sign)
+ {
+ if ($sign !== $this->sign) {
+ return Filter::expression($this->column, $sign, $this->expression);
+ }
+ return $this;
+ }
+
+ /**
+ * Set this filter's case sensitivity
+ *
+ * @param bool $caseSensitive
+ *
+ * @return $this
+ */
+ public function setCaseSensitive($caseSensitive = true)
+ {
+ $this->caseSensitive = $caseSensitive;
+ return $this;
+ }
+
+ public function listFilteredColumns()
+ {
+ return array($this->getColumn());
+ }
+
+ public function __toString()
+ {
+ if ($this->isBooleanTrue()) {
+ return $this->column;
+ }
+
+ $expression = is_array($this->expression) ?
+ '( ' . implode(' | ', $this->expression) . ' )' :
+ $this->expression;
+
+ return sprintf(
+ '%s %s %s',
+ $this->column,
+ $this->sign,
+ $expression
+ );
+ }
+
+ public function toQueryString()
+ {
+ if ($this->isBooleanTrue()) {
+ return $this->column;
+ }
+
+ $expression = is_array($this->expression) ?
+ '(' . implode('|', array_map('rawurlencode', $this->expression)) . ')' :
+ rawurlencode($this->expression);
+
+ return $this->column . $this->sign . $expression;
+ }
+
+ protected function isBooleanTrue()
+ {
+ return $this->sign === '=' && $this->expression === true;
+ }
+
+ /**
+ * If $var is a scalar, do the same as strtolower() would do.
+ * If $var is an array, map $this->strtolowerRecursive() to its elements.
+ * Otherwise, return $var unchanged.
+ *
+ * @param mixed $var
+ *
+ * @return mixed
+ */
+ protected function strtolowerRecursive($var)
+ {
+ if ($var === null) {
+ return '';
+ }
+ if (is_scalar($var)) {
+ return strtolower($var);
+ }
+ if (is_array($var)) {
+ return array_map(array($this, 'strtolowerRecursive'), $var);
+ }
+ return $var;
+ }
+
+ public function matches($row)
+ {
+ try {
+ $rowValue = $row->{$this->column};
+ } catch (Exception $e) {
+ // TODO: REALLY? Exception?
+ return false;
+ }
+
+ if ($this->caseSensitive) {
+ $expression = $this->expression;
+ } else {
+ $rowValue = $this->strtolowerRecursive($rowValue);
+ $expression = $this->strtolowerRecursive($this->expression);
+ }
+
+ if (is_array($expression)) {
+ return in_array($rowValue, $expression);
+ }
+
+ $expression = (string) $expression;
+ if (strpos($expression, '*') === false) {
+ if (is_array($rowValue)) {
+ return in_array($expression, $rowValue);
+ }
+
+ return (string) $rowValue === $expression;
+ }
+
+ $parts = array();
+ foreach (preg_split('~\*~', $expression) as $part) {
+ $parts[] = preg_quote($part, '/');
+ }
+ $pattern = '/^' . implode('.*', $parts) . '$/';
+
+ if (is_array($rowValue)) {
+ foreach ($rowValue as $candidate) {
+ if (preg_match($pattern, $candidate)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return $rowValue !== null && preg_match($pattern, $rowValue);
+ }
+
+ public function andFilter(Filter $filter)
+ {
+ return Filter::matchAll($this, $filter);
+ }
+
+ public function orFilter(Filter $filter)
+ {
+ return Filter::matchAny($this, $filter);
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterGreaterThan.php b/library/Icinga/Data/Filter/FilterGreaterThan.php
new file mode 100644
index 0000000..92a0e62
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterGreaterThan.php
@@ -0,0 +1,16 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterGreaterThan extends FilterExpression
+{
+ public function matches($row)
+ {
+ if (! isset($row->{$this->column})) {
+ // TODO: REALLY? Exception?
+ return false;
+ }
+ return (string) $row->{$this->column} > (string) $this->expression;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterLessThan.php b/library/Icinga/Data/Filter/FilterLessThan.php
new file mode 100644
index 0000000..c13a1ce
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterLessThan.php
@@ -0,0 +1,26 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterLessThan extends FilterExpression
+{
+ public function __toString()
+ {
+ return $this->column . ' < ' . $this->expression;
+ }
+
+ public function toQueryString()
+ {
+ return $this->column . '<' . $this->expression;
+ }
+
+ public function matches($row)
+ {
+ if (! isset($row->{$this->column})) {
+ return false;
+ }
+
+ return (string) $row->{$this->column} < (string) $this->expression;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterMatch.php b/library/Icinga/Data/Filter/FilterMatch.php
new file mode 100644
index 0000000..a3befad
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterMatch.php
@@ -0,0 +1,8 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterMatch extends FilterExpression
+{
+}
diff --git a/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php b/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php
new file mode 100644
index 0000000..9eca173
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php
@@ -0,0 +1,13 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterMatchCaseInsensitive extends FilterMatch
+{
+ public function __construct($column, $sign, $expression)
+ {
+ parent::__construct($column, $sign, $expression);
+ $this->caseSensitive = false;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterMatchNot.php b/library/Icinga/Data/Filter/FilterMatchNot.php
new file mode 100644
index 0000000..1e5050e
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterMatchNot.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterMatchNot extends FilterExpression
+{
+ public function matches($row)
+ {
+ return !parent::matches($row);
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php b/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php
new file mode 100644
index 0000000..3838fa2
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php
@@ -0,0 +1,13 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterMatchNotCaseInsensitive extends FilterMatchNot
+{
+ public function __construct($column, $sign, $expression)
+ {
+ parent::__construct($column, $sign, $expression);
+ $this->caseSensitive = false;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterNot.php b/library/Icinga/Data/Filter/FilterNot.php
new file mode 100644
index 0000000..b61f497
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterNot.php
@@ -0,0 +1,58 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterNot extends FilterChain
+{
+ protected $operatorName = 'NOT';
+
+ protected $operatorSymbol = '!'; // BULLSHIT
+
+// TODO: Max count 1 or autocreate sub-and?
+
+ public function matches($row)
+ {
+ foreach ($this->filters() as $filter) {
+ if ($filter->matches($row)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public function andFilter(Filter $filter)
+ {
+ return Filter::matchAll($this, $filter);
+ }
+
+ public function orFilter(Filter $filter)
+ {
+ return Filter::matchAny($filter);
+ }
+
+ public function toQueryString()
+ {
+ $parts = array();
+ if (empty($this->filters)) {
+ return '';
+ }
+
+ foreach ($this->filters() as $filter) {
+ $parts[] = $filter->toQueryString();
+ }
+ if (count($parts) === 1) {
+ return '!' . $parts[0];
+ } else {
+ return '!(' . implode('&', $parts) . ')';
+ }
+ }
+
+ public function __toString()
+ {
+ if (count($this->filters) === 1) {
+ return '! ' . $this->filters[0];
+ }
+ return '! (' . implode('&', $this->filters) . ')';
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterNotEqual.php b/library/Icinga/Data/Filter/FilterNotEqual.php
new file mode 100644
index 0000000..8915a3d
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterNotEqual.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterNotEqual extends FilterExpression
+{
+ public function matches($row)
+ {
+ return (string) $row->{$this->column} !== (string) $this->expression;
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterOr.php b/library/Icinga/Data/Filter/FilterOr.php
new file mode 100644
index 0000000..aca91f3
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterOr.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterOr extends FilterChain
+{
+ protected $operatorName = 'OR';
+
+ protected $operatorSymbol = '|';
+
+ public function matches($row)
+ {
+ foreach ($this->filters as $filter) {
+ if ($filter->matches($row)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function setOperatorName($name)
+ {
+ if ($this->count() > 1 && $name === 'NOT') {
+ return Filter::not(clone $this);
+ }
+ return parent::setOperatorName($name);
+ }
+
+ public function andFilter(Filter $filter)
+ {
+ return Filter::matchAll($this, $filter);
+ }
+
+ public function orFilter(Filter $filter)
+ {
+ return $this->addFilter($filter);
+ }
+}
diff --git a/library/Icinga/Data/Filter/FilterParseException.php b/library/Icinga/Data/Filter/FilterParseException.php
new file mode 100644
index 0000000..f2b732b
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterParseException.php
@@ -0,0 +1,10 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+use Icinga\Exception\IcingaException;
+
+class FilterParseException extends IcingaException
+{
+}
diff --git a/library/Icinga/Data/Filter/FilterQueryString.php b/library/Icinga/Data/Filter/FilterQueryString.php
new file mode 100644
index 0000000..8535df5
--- /dev/null
+++ b/library/Icinga/Data/Filter/FilterQueryString.php
@@ -0,0 +1,320 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Filter;
+
+class FilterQueryString
+{
+ protected $string;
+
+ protected $pos;
+
+ protected $debug = array();
+
+ protected $reportDebug = false;
+
+ protected $length;
+
+ protected function __construct()
+ {
+ }
+
+ protected function debug($msg, $level = 0, $op = null)
+ {
+ if ($op === null) {
+ $op = 'NULL';
+ }
+ $this->debug[] = sprintf(
+ '%s[%d=%s] (%s): %s',
+ str_repeat('* ', $level),
+ $this->pos,
+ $this->string[$this->pos - 1],
+ $op,
+ $msg
+ );
+ }
+
+ public static function parse($string)
+ {
+ $parser = new static();
+ return $parser->parseQueryString($string);
+ }
+
+ protected function readNextKey()
+ {
+ $str = $this->readUnlessSpecialChar();
+
+ if ($str === false) {
+ return $str;
+ }
+ return rawurldecode($str);
+ }
+
+ protected function readNextValue()
+ {
+ if ($this->nextChar() === '(') {
+ $this->readChar();
+ $var = preg_split('~\|~', $this->readUnless(')'));
+ if ($this->readChar() !== ')') {
+ $this->parseError(null, 'Expected ")"');
+ }
+ } else {
+ $var = rawurldecode($this->readUnless(array(')', '&', '|', '>', '<')));
+ }
+ return $var;
+ }
+
+ protected function readNextExpression()
+ {
+ if ('' === ($key = $this->readNextKey())) {
+ return false;
+ }
+
+ foreach (array('<', '>') as $sign) {
+ if (false !== ($pos = strpos($key, $sign))) {
+ if ($this->nextChar() === '=') {
+ break;
+ }
+ $var = substr($key, $pos + 1);
+ $key = substr($key, 0, $pos);
+
+ if (ctype_digit($var)) {
+ $var = (float) $var;
+ }
+
+ return Filter::expression($key, $sign, $var);
+ }
+ }
+ if (in_array($this->nextChar(), array('=', '>', '<', '!'))) {
+ $sign = $this->readChar();
+ } else {
+ $sign = false;
+ }
+ if ($sign === false) {
+ return Filter::expression($key, '=', true);
+ }
+
+ $toFloat = false;
+ if ($sign === '=') {
+ $last = substr($key, -1);
+ if ($last === '>' || $last === '<') {
+ $sign = $last . $sign;
+ $key = substr($key, 0, -1);
+ $toFloat = true;
+ }
+ // TODO: Same as above for unescaped <> - do we really need this?
+ } elseif ($sign === '>' || $sign === '<' || $sign === '!') {
+ $toFloat = $sign === '>' || $sign === '<';
+ if ($this->nextChar() === '=') {
+ $sign .= $this->readChar();
+ }
+ }
+
+ $var = $this->readNextValue();
+ if ($toFloat && ctype_digit($var)) {
+ $var = (float) $var;
+ }
+
+ return Filter::expression($key, $sign, $var);
+ }
+
+ protected function parseError($char = null, $extraMsg = null)
+ {
+ if ($extraMsg === null) {
+ $extra = '';
+ } else {
+ $extra = ': ' . $extraMsg;
+ }
+ if ($char === null) {
+ $char = $this->string[$this->pos];
+ }
+ if ($this->reportDebug) {
+ $extra .= "\n" . implode("\n", $this->debug);
+ }
+
+ throw new FilterParseException(
+ 'Invalid filter "%s", unexpected %s at pos %d%s',
+ $this->string,
+ $char,
+ $this->pos,
+ $extra
+ );
+ }
+
+ protected function readFilters($nestingLevel = 0, $op = null)
+ {
+ $filters = array();
+ while ($this->pos < $this->length) {
+ if ($op === '!' && count($filters) === 1) {
+ break;
+ }
+ $filter = $this->readNextExpression();
+ $next = $this->readChar();
+
+
+ if ($filter === false) {
+ $this->debug('Got no next expression, next is ' . $next, $nestingLevel, $op);
+ if ($next === '!') {
+ $not = $this->readFilters($nestingLevel + 1, '!');
+ $filters[] = $not;
+ if (in_array($this->nextChar(), array('|', '&', ')'))) {
+ $next = $this->readChar();
+ $this->debug('Got NOT, next is now: ' . $next, $nestingLevel, $op);
+ } else {
+ $this->debug('Breaking after NOT: ' . $not, $nestingLevel, $op);
+ break;
+ }
+ }
+
+ if ($op === null && count($filters) > 0 && ($next === '&' || $next === '|')) {
+ $op = $next;
+ continue;
+ }
+
+ if ($next === false) {
+ // Nothing more to read
+ break;
+ }
+
+ if ($next === ')') {
+ if ($nestingLevel > 0) {
+ $this->debug('Closing without filter: ' . $next, $nestingLevel, $op);
+ break;
+ }
+ $this->parseError($next);
+ }
+ if ($next === '(') {
+ $filters[] = $this->readFilters($nestingLevel + 1, null);
+ continue;
+ }
+ if ($next === $op) {
+ continue;
+ }
+ $this->parseError($next, "$op level $nestingLevel");
+ } else {
+ $this->debug('Got new expression: ' . $filter, $nestingLevel, $op);
+
+ $filters[] = $filter;
+
+ if ($next === false) {
+ $this->debug('Next is false, nothing to read but got filter', $nestingLevel, $op);
+ // Got filter, nothing more to read
+ break;
+ }
+
+ if ($op === '!') {
+ $this->pos--;
+ break;
+ }
+ if ($next === $op) {
+ $this->debug('Next matches operator', $nestingLevel, $op);
+ continue; // Break??
+ }
+
+ if ($next === ')') {
+ if ($nestingLevel > 0) {
+ $this->debug('Closing with filter: ' . $next, $nestingLevel, $op);
+ break;
+ }
+ $this->parseError($next);
+ }
+ if ($op === null && in_array($next, array('&', '|'))) {
+ $this->debug('Setting op to ' . $next, $nestingLevel, $op);
+ $op = $next;
+ continue;
+ }
+ $this->parseError($next);
+ }
+ }
+
+ if ($nestingLevel === 0 && $this->pos < $this->length) {
+ $this->parseError($op, 'Did not read full filter');
+ }
+
+ if ($nestingLevel === 0 && count($filters) === 1 && $op !== '!') {
+ // There is only one filter expression, no chain
+ $this->debug('Returning first filter only: ' . $filters[0], $nestingLevel, $op);
+ return $filters[0];
+ }
+
+ if ($op === null && count($filters) === 1) {
+ $this->debug('No op, single filter, setting AND', $nestingLevel, $op);
+ $op = '&';
+ }
+ $this->debug(sprintf('Got %d filters, returning', count($filters)), $nestingLevel, $op);
+
+ switch ($op) {
+ case '&':
+ return Filter::matchAll($filters);
+ case '|':
+ return Filter::matchAny($filters);
+ case '!':
+ return Filter::not($filters);
+ case null:
+ return Filter::matchAll();
+ default:
+ $this->parseError($op);
+ }
+ }
+
+ protected function parseQueryString($string)
+ {
+ $this->pos = 0;
+
+ $this->string = $string;
+
+ $this->length = $string ? strlen($string) : 0;
+
+ if ($this->length === 0) {
+ return Filter::matchAll();
+ }
+ return $this->readFilters();
+ }
+
+ protected function readUnless($char)
+ {
+ $buffer = '';
+ while (false !== ($c = $this->readChar())) {
+ if (is_array($char)) {
+ if (in_array($c, $char)) {
+ $this->pos--;
+ break;
+ }
+ } else {
+ if ($c === $char) {
+ $this->pos--;
+ break;
+ }
+ }
+ $buffer .= $c;
+ }
+
+ return $buffer;
+ }
+
+ protected function readUnlessSpecialChar()
+ {
+ return $this->readUnless(array('=', '(', ')', '&', '|', '>', '<', '!'));
+ }
+
+ protected function readExpressionOperator()
+ {
+ return $this->readUnless(array('=', '>', '<', '!'));
+ }
+
+ protected function readChar()
+ {
+ if ($this->length > $this->pos) {
+ return $this->string[$this->pos++];
+ }
+ return false;
+ }
+
+ protected function nextChar()
+ {
+ if ($this->length > $this->pos) {
+ return $this->string[$this->pos];
+ }
+ return false;
+ }
+}
diff --git a/library/Icinga/Data/FilterColumns.php b/library/Icinga/Data/FilterColumns.php
new file mode 100644
index 0000000..7eaacea
--- /dev/null
+++ b/library/Icinga/Data/FilterColumns.php
@@ -0,0 +1,21 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+interface FilterColumns
+{
+ /**
+ * Return a filterable's filter columns with their optional label as key
+ *
+ * @return array
+ */
+ public function getFilterColumns();
+
+ /**
+ * Return a filterable's search columns
+ *
+ * @return array
+ */
+ public function getSearchColumns();
+}
diff --git a/library/Icinga/Data/Filterable.php b/library/Icinga/Data/Filterable.php
new file mode 100644
index 0000000..ceca22f
--- /dev/null
+++ b/library/Icinga/Data/Filterable.php
@@ -0,0 +1,27 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Icinga\Data\Filter\Filter;
+
+/**
+ * Interface for filtering a result set
+ *
+ * @deprecated(EL): addFilter and applyFilter do the same in all usages.
+ * addFilter could be replaced w/ getFilter()->add(). We must no require classes implementing this interface to
+ * implement redundant methods over and over again. This interface must be moved to the namespace Icinga\Data\Filter.
+ * It lacks documentation.
+ */
+interface Filterable
+{
+ public function applyFilter(Filter $filter);
+
+ public function setFilter(Filter $filter);
+
+ public function getFilter();
+
+ public function addFilter(Filter $filter);
+
+ public function where($condition, $value = null);
+}
diff --git a/library/Icinga/Data/Identifiable.php b/library/Icinga/Data/Identifiable.php
new file mode 100644
index 0000000..7435026
--- /dev/null
+++ b/library/Icinga/Data/Identifiable.php
@@ -0,0 +1,17 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+/**
+ * Interface for objects that are identifiable by an ID of any type
+ */
+interface Identifiable
+{
+ /**
+ * Get the ID associated with this Identifiable object
+ *
+ * @return mixed
+ */
+ public function getId();
+}
diff --git a/library/Icinga/Data/Inspectable.php b/library/Icinga/Data/Inspectable.php
new file mode 100644
index 0000000..d40ce57
--- /dev/null
+++ b/library/Icinga/Data/Inspectable.php
@@ -0,0 +1,20 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+/**
+ * An object for which the user can retrieve status information
+ *
+ * This interface is useful for providing summaries or diagnostic information about objects
+ * to users.
+ */
+interface Inspectable
+{
+ /**
+ * Inspect this object to gain extended information about its health
+ *
+ * @return Inspection The inspection result
+ */
+ public function inspect();
+}
diff --git a/library/Icinga/Data/Inspection.php b/library/Icinga/Data/Inspection.php
new file mode 100644
index 0000000..b0dd298
--- /dev/null
+++ b/library/Icinga/Data/Inspection.php
@@ -0,0 +1,129 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Icinga\Application\Logger;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Contains information about an object in the form of human-readable log entries and indicates if the object has errors
+ */
+class Inspection
+{
+ /**
+ * @var array
+ */
+ protected $log = array();
+
+ /**
+ * @var string
+ */
+ protected $description;
+
+ /**
+ * @var string|Inspection
+ */
+ protected $error;
+
+ /**
+ * @param $description Describes the object that is being inspected
+ */
+ public function __construct($description)
+ {
+ $this->description = $description;
+ }
+
+ /**
+ * Get the name of this Inspection
+ *
+ * @return mixed
+ */
+ public function getDescription()
+ {
+ return $this->description;
+ }
+
+ /**
+ * Append the given log entry or nested inspection
+ *
+ * @throws ProgrammingError When called after erroring
+ *
+ * @param $entry string|Inspection A log entry or nested inspection
+ */
+ public function write($entry)
+ {
+ if (isset($this->error)) {
+ throw new ProgrammingError('Inspection object used after error');
+ }
+ if ($entry instanceof Inspection) {
+ $this->log[$entry->description] = $entry->toArray();
+ } else {
+ Logger::debug($entry);
+ $this->log[] = $entry;
+ }
+ }
+
+ /**
+ * Append the given log entry and fail this inspection with the given error
+ *
+ * @param $entry string|Inspection A log entry or nested inspection
+ *
+ * @throws ProgrammingError When called multiple times
+ *
+ * @return $this fluent interface
+ */
+ public function error($entry)
+ {
+ if (isset($this->error)) {
+ throw new ProgrammingError('Inspection object used after error');
+ }
+ Logger::error($entry);
+ $this->log[] = $entry;
+ $this->error = $entry;
+ return $this;
+ }
+
+ /**
+ * If the inspection resulted in an error
+ *
+ * @return bool
+ */
+ public function hasError()
+ {
+ return isset($this->error);
+ }
+
+ /**
+ * The error that caused the inspection to fail
+ *
+ * @return Inspection|string
+ */
+ public function getError()
+ {
+ return $this->error;
+ }
+
+ /**
+ * Convert the inspection to an array
+ *
+ * @return array An array of strings that describe the state in a human-readable form, each array element
+ * represents one log entry about this object.
+ */
+ public function toArray()
+ {
+ return $this->log;
+ }
+
+ /**
+ * Return a text representation of the inspection log entries
+ */
+ public function __toString()
+ {
+ return sprintf(
+ 'Inspection: description: "%s" error: "%s"',
+ $this->description,
+ $this->error
+ );
+ }
+}
diff --git a/library/Icinga/Data/Limitable.php b/library/Icinga/Data/Limitable.php
new file mode 100644
index 0000000..8591a79
--- /dev/null
+++ b/library/Icinga/Data/Limitable.php
@@ -0,0 +1,48 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+/**
+ * Interface for retrieving just a portion of a result set
+ */
+interface Limitable
+{
+ /**
+ * Set a limit count and offset
+ *
+ * @param int $count Number of rows to return
+ * @param int $offset Start returning after this many rows
+ *
+ * @return self
+ */
+ public function limit($count = null, $offset = null);
+
+ /**
+ * Whether a limit is set
+ *
+ * @return bool
+ */
+ public function hasLimit();
+
+ /**
+ * Get the limit if any
+ *
+ * @return int|null
+ */
+ public function getLimit();
+
+ /**
+ * Whether an offset is set
+ *
+ * @return bool
+ */
+ public function hasOffset();
+
+ /**
+ * Get the offset if any
+ *
+ * @return int|null
+ */
+ public function getOffset();
+}
diff --git a/library/Icinga/Data/Paginatable.php b/library/Icinga/Data/Paginatable.php
new file mode 100644
index 0000000..468cca2
--- /dev/null
+++ b/library/Icinga/Data/Paginatable.php
@@ -0,0 +1,10 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Countable;
+
+interface Paginatable extends Limitable, Countable
+{
+}
diff --git a/library/Icinga/Data/PivotTable.php b/library/Icinga/Data/PivotTable.php
new file mode 100644
index 0000000..6c7f806
--- /dev/null
+++ b/library/Icinga/Data/PivotTable.php
@@ -0,0 +1,396 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Application\Icinga;
+use Icinga\Web\Paginator\Adapter\QueryAdapter;
+use Zend_Paginator;
+
+class PivotTable implements Sortable
+{
+ /**
+ * The query to fetch as pivot table
+ *
+ * @var SimpleQuery
+ */
+ protected $baseQuery;
+
+ /**
+ * X-axis pivot column
+ *
+ * @var string
+ */
+ protected $xAxisColumn;
+
+ /**
+ * Y-axis pivot column
+ *
+ * @var string
+ */
+ protected $yAxisColumn;
+
+ /**
+ * Column for sorting the result set
+ *
+ * @var array
+ */
+ protected $order = array();
+
+ /**
+ * The filter being applied on the query for the x-axis
+ *
+ * @var Filter
+ */
+ protected $xAxisFilter;
+
+ /**
+ * The filter being applied on the query for the y-axis
+ *
+ * @var Filter
+ */
+ protected $yAxisFilter;
+
+ /**
+ * The query to fetch the leading x-axis rows and their headers
+ *
+ * @var SimpleQuery
+ */
+ protected $xAxisQuery;
+
+ /**
+ * The query to fetch the leading y-axis rows and their headers
+ *
+ * @var SimpleQuery
+ */
+ protected $yAxisQuery;
+
+ /**
+ * X-axis header column
+ *
+ * @var string|null
+ */
+ protected $xAxisHeader;
+
+ /**
+ * Y-axis header column
+ *
+ * @var string|null
+ */
+ protected $yAxisHeader;
+
+ /**
+ * Create a new pivot table
+ *
+ * @param SimpleQuery $query The query to fetch as pivot table
+ * @param string $xAxisColumn X-axis pivot column
+ * @param string $yAxisColumn Y-axis pivot column
+ */
+ public function __construct(SimpleQuery $query, $xAxisColumn, $yAxisColumn)
+ {
+ $this->baseQuery = $query;
+ $this->xAxisColumn = $xAxisColumn;
+ $this->yAxisColumn = $yAxisColumn;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOrder()
+ {
+ return $this->order;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hasOrder()
+ {
+ return ! empty($this->order);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function order($field, $direction = null)
+ {
+ $this->order[$field] = $direction;
+ return $this;
+ }
+
+ /**
+ * Set the filter to apply on the query for the x-axis
+ *
+ * @param Filter $filter
+ *
+ * @return $this
+ */
+ public function setXAxisFilter(Filter $filter = null)
+ {
+ $this->xAxisFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Set the filter to apply on the query for the y-axis
+ *
+ * @param Filter $filter
+ *
+ * @return $this
+ */
+ public function setYAxisFilter(Filter $filter = null)
+ {
+ $this->yAxisFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Get the x-axis header
+ *
+ * Defaults to {@link $xAxisColumn} in case no x-axis header has been set using {@link setXAxisHeader()}
+ *
+ * @return string
+ */
+ public function getXAxisHeader()
+ {
+ return $this->xAxisHeader !== null ? $this->xAxisHeader : $this->xAxisColumn;
+ }
+
+ /**
+ * Set the x-axis header
+ *
+ * @param string $xAxisHeader
+ *
+ * @return $this
+ */
+ public function setXAxisHeader($xAxisHeader)
+ {
+ $this->xAxisHeader = (string) $xAxisHeader;
+ return $this;
+ }
+
+ /**
+ * Get the y-axis header
+ *
+ * Defaults to {@link $yAxisColumn} in case no x-axis header has been set using {@link setYAxisHeader()}
+ *
+ * @return string
+ */
+ public function getYAxisHeader()
+ {
+ return $this->yAxisHeader !== null ? $this->yAxisHeader : $this->yAxisColumn;
+ }
+
+ /**
+ * Set the y-axis header
+ *
+ * @param string $yAxisHeader
+ *
+ * @return $this
+ */
+ public function setYAxisHeader($yAxisHeader)
+ {
+ $this->yAxisHeader = (string) $yAxisHeader;
+ return $this;
+ }
+
+ /**
+ * Return the value for the given request parameter
+ *
+ * @param string $axis The axis for which to return the parameter ('x' or 'y')
+ * @param string $param The parameter name to return
+ * @param int $default The default value to return
+ *
+ * @return int
+ */
+ protected function getPaginationParameter($axis, $param, $default = null)
+ {
+ $request = Icinga::app()->getRequest();
+
+ $value = $request->getParam($param, '');
+ if (strpos($value, ',') > 0) {
+ $parts = explode(',', $value, 2);
+ return intval($parts[$axis === 'x' ? 0 : 1]);
+ }
+
+ return $default !== null ? $default : 0;
+ }
+
+ /**
+ * Query horizontal (x) axis
+ *
+ * @return SimpleQuery
+ */
+ protected function queryXAxis()
+ {
+ if ($this->xAxisQuery === null) {
+ $this->xAxisQuery = clone $this->baseQuery;
+ $this->xAxisQuery->clearGroupingRules();
+ $xAxisHeader = $this->getXAxisHeader();
+ $columns = array($this->xAxisColumn, $xAxisHeader);
+ $this->xAxisQuery->group(array_unique($columns)); // xAxisColumn and header may be the same column
+ $this->xAxisQuery->columns($columns);
+
+ if ($this->xAxisFilter !== null) {
+ $this->xAxisQuery->addFilter($this->xAxisFilter);
+ }
+
+ $this->xAxisQuery->order(
+ $xAxisHeader,
+ isset($this->order[$xAxisHeader]) ? $this->order[$xAxisHeader] : self::SORT_ASC
+ );
+ }
+
+ return $this->xAxisQuery;
+ }
+
+ /**
+ * Query vertical (y) axis
+ *
+ * @return SimpleQuery
+ */
+ protected function queryYAxis()
+ {
+ if ($this->yAxisQuery === null) {
+ $this->yAxisQuery = clone $this->baseQuery;
+ $this->yAxisQuery->clearGroupingRules();
+ $yAxisHeader = $this->getYAxisHeader();
+ $columns = array($this->yAxisColumn, $yAxisHeader);
+ $this->yAxisQuery->group(array_unique($columns)); // yAxisColumn and header may be the same column
+ $this->yAxisQuery->columns($columns);
+
+ if ($this->yAxisFilter !== null) {
+ $this->yAxisQuery->addFilter($this->yAxisFilter);
+ }
+
+ $this->yAxisQuery->order(
+ $yAxisHeader,
+ isset($this->order[$yAxisHeader]) ? $this->order[$yAxisHeader] : self::SORT_ASC
+ );
+ }
+ return $this->yAxisQuery;
+ }
+
+ /**
+ * Return a pagination adapter for the x-axis query
+ *
+ * $limit and $page are taken from the current request if not given.
+ *
+ * @param int $limit The maximum amount of entries to fetch
+ * @param int $page The page to set as current one
+ *
+ * @return Zend_Paginator
+ */
+ public function paginateXAxis($limit = null, $page = null)
+ {
+ if ($limit === null || $page === null) {
+ if ($limit === null) {
+ $limit = $this->getPaginationParameter('x', 'limit', 20);
+ }
+
+ if ($page === null) {
+ $page = $this->getPaginationParameter('x', 'page', 1);
+ }
+ }
+
+ $query = $this->queryXAxis();
+ $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0);
+
+ $paginator = new Zend_Paginator(new QueryAdapter($query));
+ $paginator->setItemCountPerPage($limit);
+ $paginator->setCurrentPageNumber($page);
+ return $paginator;
+ }
+
+ /**
+ * Return a pagination adapter for the y-axis query
+ *
+ * $limit and $page are taken from the current request if not given.
+ *
+ * @param int $limit The maximum amount of entries to fetch
+ * @param int $page The page to set as current one
+ *
+ * @return Zend_Paginator
+ */
+ public function paginateYAxis($limit = null, $page = null)
+ {
+ if ($limit === null || $page === null) {
+ if ($limit === null) {
+ $limit = $this->getPaginationParameter('y', 'limit', 20);
+ }
+
+ if ($page === null) {
+ $page = $this->getPaginationParameter('y', 'page', 1);
+ }
+ }
+
+ $query = $this->queryYAxis();
+ $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0);
+
+ $paginator = new Zend_Paginator(new QueryAdapter($query));
+ $paginator->setItemCountPerPage($limit);
+ $paginator->setCurrentPageNumber($page);
+ return $paginator;
+ }
+
+ /**
+ * Return the pivot table as an array of pivot data and pivot header
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ if (($this->xAxisFilter === null && $this->yAxisFilter === null)
+ || ($this->xAxisFilter !== null && $this->yAxisFilter !== null)
+ ) {
+ $xAxis = $this->queryXAxis()->fetchPairs();
+ $yAxis = $this->queryYAxis()->fetchPairs();
+ $xAxisKeys = array_keys($xAxis);
+ $yAxisKeys = array_keys($yAxis);
+ } else {
+ if ($this->xAxisFilter !== null) {
+ $xAxis = $this->queryXAxis()->fetchPairs();
+ $xAxisKeys = array_keys($xAxis);
+ $yAxis = $this->queryYAxis()->where($this->xAxisColumn, $xAxisKeys)->fetchPairs();
+ $yAxisKeys = array_keys($yAxis);
+ } else { // $this->yAxisFilter !== null
+ $yAxis = $this->queryYAxis()->fetchPairs();
+ $yAxisKeys = array_keys($yAxis);
+ $xAxis = $this->queryXAxis()->where($this->yAxisColumn, $yAxisKeys)->fetchPairs();
+ $xAxisKeys = array_keys($xAxis);
+ }
+ }
+ $pivotData = array();
+ $pivotHeader = array(
+ 'cols' => $xAxis,
+ 'rows' => $yAxis
+ );
+ if (! empty($xAxis) && ! empty($yAxis)) {
+ $this->baseQuery
+ ->where($this->xAxisColumn, array_map(
+ function ($key) {
+ return (string) $key;
+ },
+ $xAxisKeys
+ ))
+ ->where($this->yAxisColumn, array_map(
+ function ($key) {
+ return (string) $key;
+ },
+ $yAxisKeys
+ ));
+
+ foreach ($yAxisKeys as $yAxisKey) {
+ foreach ($xAxisKeys as $xAxisKey) {
+ $pivotData[$yAxisKey][$xAxisKey] = null;
+ }
+ }
+
+ foreach ($this->baseQuery as $row) {
+ $pivotData[$row->{$this->yAxisColumn}][$row->{$this->xAxisColumn}] = $row;
+ }
+ }
+ return array($pivotData, $pivotHeader);
+ }
+}
diff --git a/library/Icinga/Data/QueryInterface.php b/library/Icinga/Data/QueryInterface.php
new file mode 100644
index 0000000..e723857
--- /dev/null
+++ b/library/Icinga/Data/QueryInterface.php
@@ -0,0 +1,8 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+interface QueryInterface extends Fetchable, Filterable, Paginatable, Sortable
+{
+}
diff --git a/library/Icinga/Data/Queryable.php b/library/Icinga/Data/Queryable.php
new file mode 100644
index 0000000..75cdc98
--- /dev/null
+++ b/library/Icinga/Data/Queryable.php
@@ -0,0 +1,20 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+/**
+ * Interface for specifying data sources
+ */
+interface Queryable
+{
+ /**
+ * Set the target and fields to query
+ *
+ * @param string $target
+ * @param array $fields
+ *
+ * @return Fetchable
+ */
+ public function from($target, array $fields = null);
+}
diff --git a/library/Icinga/Data/Reducible.php b/library/Icinga/Data/Reducible.php
new file mode 100644
index 0000000..6ece17e
--- /dev/null
+++ b/library/Icinga/Data/Reducible.php
@@ -0,0 +1,23 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\StatementException;
+
+/**
+ * Interface for data deletion
+ */
+interface Reducible
+{
+ /**
+ * Delete entries in the given target, optionally limiting the affected entries by using a filter
+ *
+ * @param string $target
+ * @param Filter $filter
+ *
+ * @throws StatementException
+ */
+ public function delete($target, Filter $filter = null);
+}
diff --git a/library/Icinga/Data/ResourceFactory.php b/library/Icinga/Data/ResourceFactory.php
new file mode 100644
index 0000000..5b477c7
--- /dev/null
+++ b/library/Icinga/Data/ResourceFactory.php
@@ -0,0 +1,138 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Icinga\Application\Config;
+use Icinga\Util\ConfigAwareFactory;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Data\Db\DbConnection;
+use Icinga\Protocol\Ldap\LdapConnection;
+use Icinga\Protocol\File\FileReader;
+
+/**
+ * Create resources from names or resource configuration
+ */
+class ResourceFactory implements ConfigAwareFactory
+{
+ /**
+ * Resource configuration
+ *
+ * @var Config
+ */
+ private static $resources;
+
+ /**
+ * Set resource configurations
+ *
+ * @param Config $config
+ */
+ public static function setConfig($config)
+ {
+ self::$resources = $config;
+ }
+
+ /**
+ * Get the configuration for a specific resource
+ *
+ * @param $resourceName String The resource's name
+ *
+ * @return ConfigObject The configuration of the resource
+ *
+ * @throws ConfigurationError
+ */
+ public static function getResourceConfig($resourceName)
+ {
+ self::assertResourcesExist();
+ $resourceConfig = self::$resources->getSection($resourceName);
+ if ($resourceConfig->isEmpty()) {
+ throw new ConfigurationError(
+ 'Cannot load resource config "%s". Resource does not exist',
+ $resourceName
+ );
+ }
+ return $resourceConfig;
+ }
+
+ /**
+ * Get the configuration of all existing resources, or all resources of the given type
+ *
+ * @param string $type Filter for resource type
+ *
+ * @return Config The resources configuration
+ */
+ public static function getResourceConfigs($type = null)
+ {
+ self::assertResourcesExist();
+ if ($type === null) {
+ return self::$resources;
+ }
+ $resources = array();
+ foreach (self::$resources as $name => $resource) {
+ if ($resource->get('type') === $type) {
+ $resources[$name] = $resource;
+ }
+ }
+ return Config::fromArray($resources);
+ }
+
+ /**
+ * Check if the existing resources are set. If not, load them from resources.ini
+ *
+ * @throws ConfigurationError
+ */
+ private static function assertResourcesExist()
+ {
+ if (self::$resources === null) {
+ self::$resources = Config::app('resources');
+ }
+ }
+
+ /**
+ * Create and return a resource based on the given configuration
+ *
+ * @param ConfigObject $config The configuration of the resource to create
+ *
+ * @return Selectable The resource
+ * @throws ConfigurationError In case of an unsupported type or invalid configuration
+ */
+ public static function createResource(ConfigObject $config)
+ {
+ switch (strtolower($config->type)) {
+ case 'db':
+ $resource = new DbConnection($config);
+ break;
+ case 'ldap':
+ if (empty($config->root_dn)) {
+ throw new ConfigurationError('LDAP root DN missing');
+ }
+
+ $resource = new LdapConnection($config);
+ break;
+ case 'file':
+ $resource = new FileReader($config);
+ break;
+ case 'ini':
+ $resource = Config::fromIni($config->ini);
+ break;
+ default:
+ throw new ConfigurationError(
+ 'Unsupported resource type "%s"',
+ $config->type
+ );
+ }
+
+ return $resource;
+ }
+
+ /**
+ * Create a resource from name
+ *
+ * @param string $resourceName
+ * @return DbConnection|LdapConnection
+ */
+ public static function create($resourceName)
+ {
+ return self::createResource(self::getResourceConfig($resourceName));
+ }
+}
diff --git a/library/Icinga/Data/Selectable.php b/library/Icinga/Data/Selectable.php
new file mode 100644
index 0000000..ace4e79
--- /dev/null
+++ b/library/Icinga/Data/Selectable.php
@@ -0,0 +1,17 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+/**
+ * Interface for classes providing a data source to fetch data from
+ */
+interface Selectable
+{
+ /**
+ * Provide a data source to fetch data from
+ *
+ * @return Queryable
+ */
+ public function select();
+}
diff --git a/library/Icinga/Data/SimpleQuery.php b/library/Icinga/Data/SimpleQuery.php
new file mode 100644
index 0000000..1ef0c27
--- /dev/null
+++ b/library/Icinga/Data/SimpleQuery.php
@@ -0,0 +1,650 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Iterator;
+use IteratorAggregate;
+use Icinga\Application\Benchmark;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+
+class SimpleQuery implements QueryInterface, Queryable, Iterator
+{
+ /**
+ * Query data source
+ *
+ * @var mixed
+ */
+ protected $ds;
+
+ /**
+ * This query's iterator
+ *
+ * @var Iterator
+ */
+ protected $iterator;
+
+ /**
+ * The current position of this query's iterator
+ *
+ * @var int
+ */
+ protected $iteratorPosition;
+
+ /**
+ * The amount of rows previously calculated
+ *
+ * @var int
+ */
+ protected $cachedCount;
+
+ /**
+ * The target you are going to query
+ *
+ * @var mixed
+ */
+ protected $target;
+
+ /**
+ * The columns you asked for
+ *
+ * All columns if null, no column if empty??? Alias handling goes here!
+ *
+ * @var array
+ */
+ protected $desiredColumns = array();
+
+ /**
+ * The columns you are interested in
+ *
+ * All columns if null, no column if empty??? Alias handling goes here!
+ *
+ * @var array
+ */
+ protected $columns = array();
+
+ /**
+ * The columns and their aliases flipped in order to handle aliased sort columns
+ *
+ * Supposed to be used and populated by $this->compare *only*.
+ *
+ * @var array
+ */
+ protected $flippedColumns;
+
+ /**
+ * The columns you're using to sort the query result
+ *
+ * @var array
+ */
+ protected $order = array();
+
+ /**
+ * Number of rows to return
+ *
+ * @var int
+ */
+ protected $limitCount;
+
+ /**
+ * Result starts with this row
+ *
+ * @var int
+ */
+ protected $limitOffset;
+
+ /**
+ * Whether to peek ahead for more results
+ *
+ * @var bool
+ */
+ protected $peekAhead;
+
+ /**
+ * Whether the query did not yield all available results
+ *
+ * @var bool
+ */
+ protected $hasMore;
+
+ protected $filter;
+
+ /**
+ * Constructor
+ *
+ * @param mixed $ds
+ */
+ public function __construct($ds, $columns = null)
+ {
+ $this->ds = $ds;
+ $this->filter = Filter::matchAll();
+ if ($columns !== null) {
+ $this->desiredColumns = $columns;
+ }
+ $this->init();
+ if ($this->desiredColumns !== null) {
+ $this->columns($this->desiredColumns);
+ }
+ }
+
+ /**
+ * Initialize query
+ *
+ * Overwrite this instead of __construct (it's called at the end of the construct) to
+ * implement custom initialization logic on construction time
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Get the data source
+ *
+ * @return mixed
+ */
+ public function getDatasource()
+ {
+ return $this->ds;
+ }
+
+ /**
+ * Return the current position of this query's iterator
+ *
+ * @return int
+ */
+ public function getIteratorPosition()
+ {
+ return $this->iteratorPosition;
+ }
+
+ /**
+ * Start or rewind the iteration
+ */
+ public function rewind(): void
+ {
+ if ($this->iterator === null) {
+ $iterator = $this->ds->query($this);
+ if ($iterator instanceof IteratorAggregate) {
+ $this->iterator = $iterator->getIterator();
+ } else {
+ $this->iterator = $iterator;
+ }
+ }
+
+ $this->iterator->rewind();
+ $this->iteratorPosition = null;
+ Benchmark::measure('Query result iteration started');
+ }
+
+ /**
+ * Fetch and return the current row of this query's result
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ return $this->iterator->current();
+ }
+
+ /**
+ * Return whether the current row of this query's result is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ $valid = $this->iterator->valid();
+ if ($valid && $this->peekAhead && $this->hasLimit() && $this->iteratorPosition + 1 === $this->getLimit()) {
+ $this->hasMore = true;
+ $valid = false; // We arrived at the last result, which is the requested extra row, so stop the iteration
+ } elseif (! $valid) {
+ $this->hasMore = false;
+ }
+
+ if (! $valid) {
+ Benchmark::measure('Query result iteration finished');
+ return false;
+ } elseif ($this->iteratorPosition === null) {
+ $this->iteratorPosition = 0;
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the key for the current row of this query's result
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->iterator->key();
+ }
+
+ /**
+ * Advance to the next row of this query's result
+ */
+ public function next(): void
+ {
+ $this->iterator->next();
+ $this->iteratorPosition += 1;
+ }
+
+ /**
+ * Choose a table and the columns you are interested in
+ *
+ * Query will return all available columns if none are given here.
+ *
+ * @param mixed $target
+ * @param array $fields
+ *
+ * @return $this
+ */
+ public function from($target, array $fields = null)
+ {
+ $this->target = $target;
+ if ($fields !== null) {
+ $this->columns($fields);
+ }
+ return $this;
+ }
+
+ /**
+ * Add a where condition to the query by and
+ *
+ * The syntax of the condition and valid values are defined by the concrete backend-specific query implementation.
+ *
+ * @param string $condition
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function where($condition, $value = null)
+ {
+ // TODO: more intelligence please
+ $this->filter->addFilter(Filter::expression($condition, '=', $value));
+ return $this;
+ }
+
+ public function getFilter()
+ {
+ return $this->filter;
+ }
+
+ public function applyFilter(Filter $filter)
+ {
+ return $this->addFilter($filter);
+ }
+
+ public function addFilter(Filter $filter)
+ {
+ $this->filter->addFilter($filter);
+ return $this;
+ }
+
+ public function setFilter(Filter $filter)
+ {
+ $this->filter = $filter;
+ return $this;
+ }
+
+ public function setOrderColumns(array $orderColumns)
+ {
+ throw new IcingaException('This function does nothing and will be removed');
+ }
+
+ /**
+ * Split order field into its field and sort direction
+ *
+ * @param string $field
+ *
+ * @return array
+ */
+ public function splitOrder($field)
+ {
+ $fieldAndDirection = explode(' ', $field, 2);
+ if (count($fieldAndDirection) === 1) {
+ $direction = null;
+ } else {
+ $field = $fieldAndDirection[0];
+ $direction = (strtoupper(trim($fieldAndDirection[1])) === 'DESC') ?
+ Sortable::SORT_DESC : Sortable::SORT_ASC;
+ }
+ return array($field, $direction);
+ }
+
+ /**
+ * Sort result set by the given field (and direction)
+ *
+ * Preferred usage:
+ * <code>
+ * $query->order('field, 'ASC')
+ * </code>
+ *
+ * @param string $field
+ * @param string $direction
+ *
+ * @return $this
+ */
+ public function order($field, $direction = null)
+ {
+ if ($direction === null) {
+ list($field, $direction) = $this->splitOrder($field);
+ if ($direction === null) {
+ $direction = Sortable::SORT_ASC;
+ }
+ } else {
+ switch (($direction = strtoupper($direction))) {
+ case Sortable::SORT_ASC:
+ case Sortable::SORT_DESC:
+ break;
+ default:
+ $direction = Sortable::SORT_ASC;
+ break;
+ }
+ }
+ $this->order[] = array($field, $direction);
+ return $this;
+ }
+
+ /**
+ * Compare $a with $b based on this query's sort rules and column aliases
+ *
+ * @param object $a
+ * @param object $b
+ * @param int $orderIndex
+ *
+ * @return int
+ */
+ public function compare($a, $b, $orderIndex = 0)
+ {
+ if (! array_key_exists($orderIndex, $this->order)) {
+ return 0; // Last column to sort reached, rows are considered being equal
+ }
+
+ if ($this->flippedColumns === null) {
+ $this->flippedColumns = array_flip($this->columns);
+ }
+
+ $column = $this->order[$orderIndex][0];
+ if (array_key_exists($column, $this->flippedColumns) && is_string($this->flippedColumns[$column])) {
+ $column = $this->flippedColumns[$column];
+ }
+
+ $result = strcmp(strtolower($a->$column ?? ''), strtolower($b->$column ?? ''));
+ if ($result === 0) {
+ return $this->compare($a, $b, ++$orderIndex);
+ }
+
+ $direction = $this->order[$orderIndex][1];
+ if ($direction === self::SORT_ASC) {
+ return $result;
+ } else {
+ return $result * -1;
+ }
+ }
+
+ /**
+ * Clear the order if any
+ *
+ * @return $this
+ */
+ public function clearOrder()
+ {
+ $this->order = array();
+ return $this;
+ }
+
+ /**
+ * Whether an order is set
+ *
+ * @return bool
+ */
+ public function hasOrder()
+ {
+ return ! empty($this->order);
+ }
+
+ /**
+ * Get the order
+ *
+ * @return array
+ */
+ public function getOrder()
+ {
+ return $this->order;
+ }
+
+ /**
+ * Set whether this query should peek ahead for more results
+ *
+ * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will
+ * be removed from the result set. Note that this only applies when fetching multiple results of limited queries.
+ *
+ * @return $this
+ */
+ public function peekAhead($state = true)
+ {
+ $this->peekAhead = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether this query did not yield all available results
+ *
+ * @return bool
+ *
+ * @throws ProgrammingError In case the query did not run yet
+ */
+ public function hasMore()
+ {
+ if ($this->hasMore === null) {
+ throw new ProgrammingError('Query did not run. Cannot determine whether there are more results.');
+ }
+
+ return $this->hasMore;
+ }
+
+ /**
+ * Return whether this query will or has yielded any result
+ *
+ * @return bool
+ */
+ public function hasResult()
+ {
+ return $this->cachedCount > 0 || $this->iteratorPosition !== null || $this->fetchRow() !== false;
+ }
+
+ /**
+ * Set a limit count and offset to the query
+ *
+ * @param int $count Number of rows to return
+ * @param int $offset Start returning after this many rows
+ *
+ * @return $this
+ */
+ public function limit($count = null, $offset = null)
+ {
+ $this->limitCount = $count !== null ? (int) $count : null;
+ $this->limitOffset = (int) $offset;
+ return $this;
+ }
+
+ /**
+ * Whether a limit is set
+ *
+ * @return bool
+ */
+ public function hasLimit()
+ {
+ return $this->limitCount !== null && $this->limitCount > 0;
+ }
+
+ /**
+ * Get the limit if any
+ *
+ * @return int|null
+ */
+ public function getLimit()
+ {
+ return $this->peekAhead && $this->hasLimit() ? $this->limitCount + 1 : $this->limitCount;
+ }
+
+ /**
+ * Whether an offset is set
+ *
+ * @return bool
+ */
+ public function hasOffset()
+ {
+ return $this->limitOffset > 0;
+ }
+
+ /**
+ * Get the offset if any
+ *
+ * @return int|null
+ */
+ public function getOffset()
+ {
+ return $this->limitOffset;
+ }
+
+ /**
+ * Retrieve an array containing all rows of the result set
+ *
+ * @return array
+ */
+ public function fetchAll()
+ {
+ Benchmark::measure('Fetching all results started');
+ $results = $this->ds->fetchAll($this);
+ Benchmark::measure('Fetching all results finished');
+
+ if ($this->peekAhead && $this->hasLimit() && count($results) === $this->getLimit()) {
+ $this->hasMore = true;
+ array_pop($results);
+ } else {
+ $this->hasMore = false;
+ }
+
+ return $results;
+ }
+
+ /**
+ * Fetch the first row of the result set
+ *
+ * @return mixed
+ */
+ public function fetchRow()
+ {
+ Benchmark::measure('Fetching one row started');
+ $row = $this->ds->fetchRow($this);
+ Benchmark::measure('Fetching one row finished');
+ return $row;
+ }
+
+ /**
+ * Fetch the first column of all rows of the result set as an array
+ *
+ * @return array
+ */
+ public function fetchColumn()
+ {
+ Benchmark::measure('Fetching one column started');
+ $values = $this->ds->fetchColumn($this);
+ Benchmark::measure('Fetching one column finished');
+
+ if ($this->peekAhead && $this->hasLimit() && count($values) === $this->getLimit()) {
+ $this->hasMore = true;
+ array_pop($values);
+ } else {
+ $this->hasMore = false;
+ }
+
+ return $values;
+ }
+
+ /**
+ * Fetch the first column of the first row of the result set
+ *
+ * @return string
+ */
+ public function fetchOne()
+ {
+ Benchmark::measure('Fetching one value started');
+ $value = $this->ds->fetchOne($this);
+ Benchmark::measure('Fetching one value finished');
+ return $value;
+ }
+
+ /**
+ * Fetch all rows of the result set as an array of key-value pairs
+ *
+ * The first column is the key, the second column is the value.
+ *
+ * @return array
+ */
+ public function fetchPairs()
+ {
+ Benchmark::measure('Fetching pairs started');
+ $pairs = $this->ds->fetchPairs($this);
+ Benchmark::measure('Fetching pairs finished');
+
+ if ($this->peekAhead && $this->hasLimit() && count($pairs) === $this->getLimit()) {
+ $this->hasMore = true;
+ array_pop($pairs);
+ } else {
+ $this->hasMore = false;
+ }
+
+ return $pairs;
+ }
+
+ /**
+ * Count all rows of the result set, ignoring limit and offset
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ $query = clone $this;
+ $query->limit(0, 0);
+ Benchmark::measure('Counting all results started');
+ $count = $this->ds->count($query);
+ $this->cachedCount = $count;
+ Benchmark::measure('Counting all results finished');
+ return $count;
+ }
+
+ /**
+ * Set columns
+ *
+ * @param array $columns
+ *
+ * @return $this
+ */
+ public function columns(array $columns)
+ {
+ $this->columns = $columns;
+ $this->flippedColumns = null; // Reset, due to updated columns
+ return $this;
+ }
+
+ public function getColumns()
+ {
+ return $this->columns;
+ }
+
+ /**
+ * Deep clone self::$filter
+ */
+ public function __clone()
+ {
+ $this->filter = clone $this->filter;
+ }
+}
diff --git a/library/Icinga/Data/SortRules.php b/library/Icinga/Data/SortRules.php
new file mode 100644
index 0000000..c93bdda
--- /dev/null
+++ b/library/Icinga/Data/SortRules.php
@@ -0,0 +1,14 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+interface SortRules
+{
+ /**
+ * Return some sort rules
+ *
+ * @return array
+ */
+ public function getSortRules();
+}
diff --git a/library/Icinga/Data/Sortable.php b/library/Icinga/Data/Sortable.php
new file mode 100644
index 0000000..11d38c3
--- /dev/null
+++ b/library/Icinga/Data/Sortable.php
@@ -0,0 +1,49 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+/**
+ * Interface for sorting a result set
+ */
+interface Sortable
+{
+ /**
+ * Sort ascending
+ */
+ const SORT_ASC = 'ASC';
+
+ /**
+ * Sort descending
+ */
+ const SORT_DESC = 'DESC';
+
+ /**
+ * Sort result set by the given field (and direction)
+ *
+ * Preferred usage:
+ * <code>
+ * $query->order('field, 'ASC')
+ * </code>
+ *
+ * @param string $field
+ * @param string $direction
+ *
+ * @return self
+ */
+ public function order($field, $direction = null);
+
+ /**
+ * Whether an order is set
+ *
+ * @return bool
+ */
+ public function hasOrder();
+
+ /**
+ * Get the order if any
+ *
+ * @return array|null
+ */
+ public function getOrder();
+}
diff --git a/library/Icinga/Data/Tree/SimpleTree.php b/library/Icinga/Data/Tree/SimpleTree.php
new file mode 100644
index 0000000..e89f589
--- /dev/null
+++ b/library/Icinga/Data/Tree/SimpleTree.php
@@ -0,0 +1,90 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Tree;
+
+use IteratorAggregate;
+use LogicException;
+use Traversable;
+
+/**
+ * A simple tree
+ */
+class SimpleTree implements IteratorAggregate
+{
+ /**
+ * Root node
+ *
+ * @var TreeNode
+ */
+ protected $sentinel;
+
+ /**
+ * Nodes
+ *
+ * @var array
+ */
+ protected $nodes = array();
+
+ /**
+ * Create a new simple tree
+ */
+ public function __construct()
+ {
+ $this->sentinel = new TreeNode();
+ }
+
+ /**
+ * Add a child node
+ *
+ * @param TreeNode $child
+ * @param TreeNode $parent
+ *
+ * @return $this
+ */
+ public function addChild(TreeNode $child, TreeNode $parent = null)
+ {
+ if ($parent === null) {
+ $parent = $this->sentinel;
+ } elseif (! isset($this->nodes[$parent->getId()])) {
+ throw new LogicException(sprintf(
+ 'Can\'t append child node %s to parent node %s: Parent node does not exist',
+ $child->getId(),
+ $parent->getId()
+ ));
+ }
+ if (isset($this->nodes[$child->getId()])) {
+ throw new LogicException(sprintf(
+ 'Can\'t append child node %s to parent node %s: Child node does already exist',
+ $child->getId(),
+ $parent->getId()
+ ));
+ }
+ $this->nodes[$child->getId()] = $child;
+ $parent->appendChild($child);
+ return $this;
+ }
+
+ /**
+ * Get a node by its ID
+ *
+ * @param mixed $id
+ *
+ * @return TreeNode|null
+ */
+ public function getNode($id)
+ {
+ if (! isset($this->nodes[$id])) {
+ return null;
+ }
+ return $this->nodes[$id];
+ }
+
+ /**
+ * @return TreeNodeIterator
+ */
+ public function getIterator(): Traversable
+ {
+ return new TreeNodeIterator($this->sentinel);
+ }
+}
diff --git a/library/Icinga/Data/Tree/TreeNode.php b/library/Icinga/Data/Tree/TreeNode.php
new file mode 100644
index 0000000..66bce79
--- /dev/null
+++ b/library/Icinga/Data/Tree/TreeNode.php
@@ -0,0 +1,109 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Tree;
+
+use Icinga\Data\Identifiable;
+
+class TreeNode implements Identifiable
+{
+ /**
+ * The node's ID
+ *
+ * @var mixed
+ */
+ protected $id;
+
+ /**
+ * The node's value
+ *
+ * @var mixed
+ */
+ protected $value;
+
+ /**
+ * The node's children
+ *
+ * @var array
+ */
+ protected $children = array();
+
+ /**
+ * Set the node's ID
+ *
+ * @param mixed $id ID of the node
+ *
+ * @return $this
+ */
+ public function setId($id)
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ /**
+ * (non-PHPDoc)
+ * @see Identifiable::getId() For the method documentation.
+ */
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ /**
+ * Set the node's value
+ *
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function setValue($value)
+ {
+ $this->value = $value;
+ return $this;
+ }
+
+ /**
+ * Get the node's value
+ *
+ * @return mixed
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Append a child node as the last child of this node
+ *
+ * @param TreeNode $child The child to append
+ *
+ * @return $this
+ */
+ public function appendChild(TreeNode $child)
+ {
+ $this->children[] = $child;
+ return $this;
+ }
+
+
+ /**
+ * Get whether the node has children
+ *
+ * @return bool
+ */
+ public function hasChildren()
+ {
+ return ! empty($this->children);
+ }
+
+ /**
+ * Get the node's children
+ *
+ * @return array
+ */
+ public function getChildren()
+ {
+ return $this->children;
+ }
+}
diff --git a/library/Icinga/Data/Tree/TreeNodeIterator.php b/library/Icinga/Data/Tree/TreeNodeIterator.php
new file mode 100644
index 0000000..1c71787
--- /dev/null
+++ b/library/Icinga/Data/Tree/TreeNodeIterator.php
@@ -0,0 +1,75 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data\Tree;
+
+use ArrayIterator;
+use RecursiveIterator;
+
+/**
+ * Iterator over a tree node's children
+ */
+class TreeNodeIterator implements RecursiveIterator
+{
+ /**
+ * The node's children
+ *
+ * @var ArrayIterator
+ */
+ protected $children;
+
+ /**
+ * Create a new iterator over a tree node's children
+ *
+ * @param TreeNode $node
+ */
+ public function __construct(TreeNode $node)
+ {
+ $this->children = new ArrayIterator($node->getChildren());
+ }
+
+ public function current(): TreeNode
+ {
+ return $this->children->current();
+ }
+
+ public function key(): int
+ {
+ return $this->children->key();
+ }
+
+ public function next(): void
+ {
+ $this->children->next();
+ }
+
+ public function rewind(): void
+ {
+ $this->children->rewind();
+ }
+
+ public function valid(): bool
+ {
+ return $this->children->valid();
+ }
+
+ public function hasChildren(): bool
+ {
+ return $this->current()->hasChildren();
+ }
+
+ public function getChildren(): TreeNodeIterator
+ {
+ return new static($this->current());
+ }
+
+ /**
+ * Get whether the iterator is empty
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return ! $this->children->count();
+ }
+}
diff --git a/library/Icinga/Data/Updatable.php b/library/Icinga/Data/Updatable.php
new file mode 100644
index 0000000..ff70b99
--- /dev/null
+++ b/library/Icinga/Data/Updatable.php
@@ -0,0 +1,24 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Data;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\StatementException;
+
+/**
+ * Interface for data updating
+ */
+interface Updatable
+{
+ /**
+ * Update the target with the given data and optionally limit the affected entries by using a filter
+ *
+ * @param string $target
+ * @param array $data
+ * @param Filter $filter
+ *
+ * @throws StatementException
+ */
+ public function update($target, array $data, Filter $filter = null);
+}
diff --git a/library/Icinga/Date/DateFormatter.php b/library/Icinga/Date/DateFormatter.php
new file mode 100644
index 0000000..867462a
--- /dev/null
+++ b/library/Icinga/Date/DateFormatter.php
@@ -0,0 +1,265 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Date;
+
+/**
+ * Date formatting
+ */
+class DateFormatter
+{
+ /**
+ * Format relative
+ *
+ * @var int
+ */
+ const RELATIVE = 0;
+
+ /**
+ * Format time
+ *
+ * @var int
+ */
+ const TIME = 1;
+
+ /**
+ * Format date
+ *
+ * @var int
+ */
+ const DATE = 2;
+
+ /**
+ * Format date and time
+ *
+ * @var int
+ */
+ const DATETIME = 4;
+
+ /**
+ * Get the diff between the given time and the current time
+ *
+ * @param int|float $time
+ * @param bool $requireTime
+ *
+ * @return array
+ */
+ protected static function diff($time, $requireTime = false)
+ {
+ $invert = false;
+ $now = time();
+ $time = (int) $time;
+ $diff = $time - $now;
+ if ($diff < 0) {
+ $diff = abs($diff);
+ $invert = true;
+ }
+ if ($diff > 3600 * 24 * 3) {
+ $type = static::DATE;
+ if (date('Y') === date('Y', $time)) {
+ $formatted = date($requireTime ? 'M j H:i' : 'M j', $time);
+ } else {
+ $formatted = date($requireTime ? 'Y-m-d H:i' : 'Y-m', $time);
+ }
+ } else {
+ $minutes = floor($diff / 60);
+ if ($minutes < 60) {
+ $type = static::RELATIVE;
+ $formatted = sprintf('%dm %ds', $minutes, $diff % 60);
+ } else {
+ $hours = floor($minutes / 60);
+ if ($hours < 24) {
+ if (date('d') === date('d', $time)) {
+ $type = static::TIME;
+ $formatted = date('H:i', $time);
+ } else {
+ $type = static::DATE;
+ $formatted = date('M j H:i', $time);
+ }
+ } else {
+ $type = static::RELATIVE;
+ $formatted = sprintf('%dd %dh', floor($hours / 24), $hours % 24);
+ }
+ }
+ }
+ return array($type, $formatted, $invert);
+ }
+
+ /**
+ * Format date
+ *
+ * @param int|float $date
+ *
+ * @return string
+ */
+ public static function formatDate($date)
+ {
+ return date('Y-m-d', (int) $date);
+ }
+
+ /**
+ * Format date and time
+ *
+ * @param int|float $dateTime
+ *
+ * @return string
+ */
+ public static function formatDateTime($dateTime)
+ {
+ return date('Y-m-d H:i:s', (int) $dateTime);
+ }
+
+ /**
+ * Format a duration
+ *
+ * @param int|float $seconds Duration in seconds
+ *
+ * @return string
+ */
+ public static function formatDuration($seconds)
+ {
+ $minutes = floor((float) $seconds / 60);
+ if ($minutes < 60) {
+ $formatted = sprintf('%dm %ds', $minutes, $seconds % 60);
+ } else {
+ $hours = floor($minutes / 60);
+ if ($hours < 24) {
+ $formatted = sprintf('%dh %dm', $hours, $minutes % 60);
+ } else {
+ $formatted = sprintf('%dd %dh', floor($hours / 24), $hours % 24);
+ }
+ }
+ return $formatted;
+ }
+
+ /**
+ * Format time
+ *
+ * @param int|float $time
+ *
+ * @return string
+ */
+ public static function formatTime($time)
+ {
+ return date('H:i:s', (int) $time);
+ }
+
+ /**
+ * Format time as time ago
+ *
+ * @param int|float $time
+ * @param bool $timeOnly
+ * @param bool $requireTime
+ *
+ * @return ?string
+ */
+ public static function timeAgo($time, $timeOnly = false, $requireTime = false)
+ {
+ list($type, $ago, $invert) = static::diff($time, $requireTime);
+ if ($timeOnly) {
+ return $ago;
+ }
+
+ $formatted = null;
+ switch ($type) {
+ case static::DATE:
+ // Move to next case
+ case static::DATETIME:
+ $formatted = sprintf(
+ t('on %s', 'An event happened on the given date or date and time'),
+ $ago
+ );
+ break;
+ case static::RELATIVE:
+ $formatted = sprintf(
+ t('%s ago', 'An event that happened the given time interval ago'),
+ $ago
+ );
+ break;
+ case static::TIME:
+ $formatted = sprintf(t('at %s', 'An event happened at the given time'), $ago);
+ break;
+ }
+ return $formatted;
+ }
+
+ /**
+ * Format time as time since
+ *
+ * @param int|float $time
+ * @param bool $timeOnly
+ * @param bool $requireTime
+ *
+ * @return ?string
+ */
+ public static function timeSince($time, $timeOnly = false, $requireTime = false)
+ {
+ list($type, $since, $invert) = static::diff($time, $requireTime);
+ if ($timeOnly) {
+ return $since;
+ }
+
+ $formatted = null;
+ switch ($type) {
+ case static::RELATIVE:
+ $formatted = sprintf(
+ t('for %s', 'A status is lasting for the given time interval'),
+ $since
+ );
+ break;
+ case static::DATE:
+ // Move to next case
+ case static::DATETIME:
+ // Move to next case
+ case static::TIME:
+ $formatted = sprintf(
+ t('since %s', 'A status is lasting since the given time, date or date and time'),
+ $since
+ );
+ break;
+ }
+ return $formatted;
+ }
+
+ /**
+ * Format time as time until
+ *
+ * @param int|float $time
+ * @param bool $timeOnly
+ * @param bool $requireTime
+ *
+ * @return ?string
+ */
+ public static function timeUntil($time, $timeOnly = false, $requireTime = false)
+ {
+ list($type, $until, $invert) = static::diff($time, $requireTime);
+ if ($invert && $type === static::RELATIVE) {
+ $until = '-' . $until;
+ }
+ if ($timeOnly) {
+ return $until;
+ }
+
+ $formatted = null;
+ switch ($type) {
+ case static::DATE:
+ // Move to next case
+ case static::DATETIME:
+ $formatted = sprintf(
+ t('on %s', 'An event will happen on the given date or date and time'),
+ $until
+ );
+ break;
+ case static::RELATIVE:
+ $formatted = sprintf(
+ t('in %s', 'An event will happen after the given time interval has elapsed'),
+ $until
+ );
+ break;
+ case static::TIME:
+ $formatted = sprintf(t('at %s', 'An event will happen at the given time'), $until);
+ break;
+ }
+ return $formatted;
+ }
+}
diff --git a/library/Icinga/Exception/AlreadyExistsException.php b/library/Icinga/Exception/AlreadyExistsException.php
new file mode 100644
index 0000000..d70c58f
--- /dev/null
+++ b/library/Icinga/Exception/AlreadyExistsException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Exception thrown if something to add already exists
+ */
+class AlreadyExistsException extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/AuthenticationException.php b/library/Icinga/Exception/AuthenticationException.php
new file mode 100644
index 0000000..50910b8
--- /dev/null
+++ b/library/Icinga/Exception/AuthenticationException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Exception thrown if an error occurs during authentication
+ */
+class AuthenticationException extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/ConfigurationError.php b/library/Icinga/Exception/ConfigurationError.php
new file mode 100644
index 0000000..e66ec46
--- /dev/null
+++ b/library/Icinga/Exception/ConfigurationError.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Class ConfigurationError
+ * @package Icinga\Exception
+ */
+class ConfigurationError extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/Http/BaseHttpException.php b/library/Icinga/Exception/Http/BaseHttpException.php
new file mode 100644
index 0000000..cad41c6
--- /dev/null
+++ b/library/Icinga/Exception/Http/BaseHttpException.php
@@ -0,0 +1,73 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Http;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Base class for HTTP exceptions
+ */
+class BaseHttpException extends IcingaException implements HttpExceptionInterface
+{
+ /**
+ * This exception's HTTP status code
+ *
+ * @var int
+ */
+ protected $statusCode;
+
+ /**
+ * This exception's HTTP response headers
+ *
+ * @var array
+ */
+ protected $headers;
+
+ /**
+ * Return this exception's HTTP status code
+ *
+ * @return int
+ */
+ public function getStatusCode()
+ {
+ return $this->statusCode;
+ }
+
+ /**
+ * Set this exception's HTTP response headers
+ *
+ * @param array $headers
+ *
+ * @return $this
+ */
+ public function setHeaders(array $headers)
+ {
+ $this->headers = $headers;
+ return $this;
+ }
+
+ /**
+ * Set/Add a HTTP response header
+ *
+ * @param string $name
+ * @param string $value
+ *
+ * @return $this
+ */
+ public function setHeader($name, $value)
+ {
+ $this->headers[$name] = $value;
+ return $this;
+ }
+
+ /**
+ * Return this exception's HTTP response headers
+ *
+ * @return array An array where each key is a header name and the value its value
+ */
+ public function getHeaders()
+ {
+ return $this->headers ?: array();
+ }
+}
diff --git a/library/Icinga/Exception/Http/HttpBadRequestException.php b/library/Icinga/Exception/Http/HttpBadRequestException.php
new file mode 100644
index 0000000..004eabd
--- /dev/null
+++ b/library/Icinga/Exception/Http/HttpBadRequestException.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Http;
+
+/**
+ * Exception thrown for sending a HTTP 400 response w/ a custom message
+ */
+class HttpBadRequestException extends BaseHttpException
+{
+ protected $statusCode = 400;
+}
diff --git a/library/Icinga/Exception/Http/HttpException.php b/library/Icinga/Exception/Http/HttpException.php
new file mode 100644
index 0000000..cd6b543
--- /dev/null
+++ b/library/Icinga/Exception/Http/HttpException.php
@@ -0,0 +1,25 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Http;
+
+class HttpException extends BaseHttpException
+{
+ /**
+ * Create a new HttpException
+ *
+ * @param int $statusCode HTTP status code
+ * @param string $message Exception message or exception format string
+ * @param mixed ...$arg Format string argument
+ *
+ * If there is at least one exception, the last one will be used for exception chaining.
+ */
+ public function __construct($statusCode, $message)
+ {
+ $this->statusCode = (int) $statusCode;
+
+ $args = func_get_args();
+ array_shift($args);
+ call_user_func_array('parent::__construct', $args);
+ }
+}
diff --git a/library/Icinga/Exception/Http/HttpExceptionInterface.php b/library/Icinga/Exception/Http/HttpExceptionInterface.php
new file mode 100644
index 0000000..c5e0cc7
--- /dev/null
+++ b/library/Icinga/Exception/Http/HttpExceptionInterface.php
@@ -0,0 +1,21 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Http;
+
+interface HttpExceptionInterface
+{
+ /**
+ * Return this exception's HTTP status code
+ *
+ * @return int
+ */
+ public function getStatusCode();
+
+ /**
+ * Return this exception's HTTP response headers
+ *
+ * @return array An array where each key is a header name and the value its value
+ */
+ public function getHeaders();
+}
diff --git a/library/Icinga/Exception/Http/HttpMethodNotAllowedException.php b/library/Icinga/Exception/Http/HttpMethodNotAllowedException.php
new file mode 100644
index 0000000..4e40b6a
--- /dev/null
+++ b/library/Icinga/Exception/Http/HttpMethodNotAllowedException.php
@@ -0,0 +1,36 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Http;
+
+/**
+ * Exception thrown if the HTTP method is not allowed
+ */
+class HttpMethodNotAllowedException extends BaseHttpException
+{
+ protected $statusCode = 405;
+
+ /**
+ * Get the allowed HTTP methods
+ *
+ * @return string
+ */
+ public function getAllowedMethods()
+ {
+ $headers = $this->getHeaders();
+ return isset($headers['Allow']) ? $headers['Allow'] : null;
+ }
+
+ /**
+ * Set the allowed HTTP methods
+ *
+ * @param string $allowedMethods
+ *
+ * @return $this
+ */
+ public function setAllowedMethods($allowedMethods)
+ {
+ $this->setHeader('Allow', (string) $allowedMethods);
+ return $this;
+ }
+}
diff --git a/library/Icinga/Exception/Http/HttpNotFoundException.php b/library/Icinga/Exception/Http/HttpNotFoundException.php
new file mode 100644
index 0000000..eb91d63
--- /dev/null
+++ b/library/Icinga/Exception/Http/HttpNotFoundException.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Http;
+
+/**
+ * Exception thrown for sending a HTTP 404 response w/ a custom message
+ */
+class HttpNotFoundException extends BaseHttpException
+{
+ protected $statusCode = 404;
+}
diff --git a/library/Icinga/Exception/IcingaException.php b/library/Icinga/Exception/IcingaException.php
new file mode 100644
index 0000000..f3d06d1
--- /dev/null
+++ b/library/Icinga/Exception/IcingaException.php
@@ -0,0 +1,114 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+use Exception;
+use ReflectionClass;
+use Throwable;
+
+class IcingaException extends Exception
+{
+ /**
+ * Create a new exception
+ *
+ * @param string $message Exception message or exception format string
+ * @param mixed ...$arg Format string argument
+ *
+ * If there is at least one exception, the last one will be used for exception chaining.
+ */
+ public function __construct($message)
+ {
+ $args = array_slice(func_get_args(), 1);
+ $exc = null;
+ foreach ($args as &$arg) {
+ if ($arg instanceof Throwable) {
+ $exc = $arg;
+ }
+ }
+
+ if (! empty($args)) {
+ $message = vsprintf($message, $args);
+ }
+
+ parent::__construct($message, 0, $exc);
+ }
+
+ /**
+ * Create the exception from an array of arguments
+ *
+ * @param array $args
+ *
+ * @return static
+ */
+ public static function create(array $args)
+ {
+ $e = new ReflectionClass(get_called_class());
+ return $e->newInstanceArgs($args);
+ }
+
+ /**
+ * Return the given exception formatted as one-liner
+ *
+ * The format used is: %class% in %path%:%line% with message: %message%
+ *
+ * @param Throwable $exception
+ *
+ * @return string
+ */
+ public static function describe(Throwable $exception)
+ {
+ return sprintf(
+ '%s in %s:%d with message: %s',
+ get_class($exception),
+ $exception->getFile(),
+ $exception->getLine(),
+ $exception->getMessage()
+ );
+ }
+
+ /**
+ * Return the same as {@link Exception::getTraceAsString()} for the given exception,
+ * but show only the types of scalar arguments
+ *
+ * @param Throwable $exception
+ *
+ * @return string
+ */
+ public static function getConfidentialTraceAsString(Throwable $exception)
+ {
+ $trace = array();
+
+ $index = 0;
+ foreach ($exception->getTrace() as $index => $frame) {
+ $trace[] = isset($frame['file'])
+ ? "#{$index} {$frame['file']}({$frame['line']}): "
+ : "#{$index} [internal function]: ";
+
+ if (isset($frame['class'])) {
+ $trace[] = $frame['class'];
+ }
+
+ if (isset($frame['type'])) {
+ $trace[] = $frame['type'];
+ }
+
+ $trace[] = "{$frame['function']}(";
+
+ if (isset($frame['args'])) {
+ $args = array();
+ foreach ($frame['args'] as $arg) {
+ $type = gettype($arg);
+ $args[] = $type === 'object' ? 'Object(' . get_class($arg) . ')' : ucfirst($type);
+ }
+
+ $trace[] = implode(', ', $args);
+ }
+ $trace[] = ")\n";
+ }
+
+ $trace[] = '#' . ($index + 1) . ' {main}';
+
+ return implode($trace);
+ }
+}
diff --git a/library/Icinga/Exception/InvalidPropertyException.php b/library/Icinga/Exception/InvalidPropertyException.php
new file mode 100644
index 0000000..e7bcf32
--- /dev/null
+++ b/library/Icinga/Exception/InvalidPropertyException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Exception thrown if a property does not exist
+ */
+class InvalidPropertyException extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/Json/JsonDecodeException.php b/library/Icinga/Exception/Json/JsonDecodeException.php
new file mode 100644
index 0000000..978eb30
--- /dev/null
+++ b/library/Icinga/Exception/Json/JsonDecodeException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Json;
+
+/**
+ * Exception thrown by {@link \Icinga\Util\Json::decode()} on failure
+ */
+class JsonDecodeException extends JsonException
+{
+}
diff --git a/library/Icinga/Exception/Json/JsonEncodeException.php b/library/Icinga/Exception/Json/JsonEncodeException.php
new file mode 100644
index 0000000..0bcc6c0
--- /dev/null
+++ b/library/Icinga/Exception/Json/JsonEncodeException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Json;
+
+/**
+ * Exception thrown by {@link \Icinga\Util\Json::encode()} on failure
+ */
+class JsonEncodeException extends JsonException
+{
+}
diff --git a/library/Icinga/Exception/Json/JsonException.php b/library/Icinga/Exception/Json/JsonException.php
new file mode 100644
index 0000000..2ca3605
--- /dev/null
+++ b/library/Icinga/Exception/Json/JsonException.php
@@ -0,0 +1,13 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception\Json;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Exception thrown by {@link \Icinga\Util\Json} on failure
+ */
+abstract class JsonException extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/MissingParameterException.php b/library/Icinga/Exception/MissingParameterException.php
new file mode 100644
index 0000000..a8bd78d
--- /dev/null
+++ b/library/Icinga/Exception/MissingParameterException.php
@@ -0,0 +1,40 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Exception thrown if a mandatory parameter was not given
+ */
+class MissingParameterException extends IcingaException
+{
+ /**
+ * Name of the missing parameter
+ *
+ * @var string
+ */
+ protected $parameter;
+
+ /**
+ * Get the name of the missing parameter
+ *
+ * @return string
+ */
+ public function getParameter()
+ {
+ return $this->parameter;
+ }
+
+ /**
+ * Set the name of the missing parameter
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setParameter($name)
+ {
+ $this->parameter = (string) $name;
+ return $this;
+ }
+}
diff --git a/library/Icinga/Exception/NotFoundError.php b/library/Icinga/Exception/NotFoundError.php
new file mode 100644
index 0000000..74e6941
--- /dev/null
+++ b/library/Icinga/Exception/NotFoundError.php
@@ -0,0 +1,8 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+class NotFoundError extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/NotImplementedError.php b/library/Icinga/Exception/NotImplementedError.php
new file mode 100644
index 0000000..395b4b2
--- /dev/null
+++ b/library/Icinga/Exception/NotImplementedError.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Class NotImplementedError
+ * @package Icinga\Exception
+ */
+class NotImplementedError extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/NotReadableError.php b/library/Icinga/Exception/NotReadableError.php
new file mode 100644
index 0000000..6bf2b3c
--- /dev/null
+++ b/library/Icinga/Exception/NotReadableError.php
@@ -0,0 +1,8 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+class NotReadableError extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/NotWritableError.php b/library/Icinga/Exception/NotWritableError.php
new file mode 100644
index 0000000..efe1fbb
--- /dev/null
+++ b/library/Icinga/Exception/NotWritableError.php
@@ -0,0 +1,8 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+class NotWritableError extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/ProgrammingError.php b/library/Icinga/Exception/ProgrammingError.php
new file mode 100644
index 0000000..02d4b47
--- /dev/null
+++ b/library/Icinga/Exception/ProgrammingError.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Class ProgrammingError
+ * @package Icinga\Exception
+ */
+class ProgrammingError extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/QueryException.php b/library/Icinga/Exception/QueryException.php
new file mode 100644
index 0000000..9344b86
--- /dev/null
+++ b/library/Icinga/Exception/QueryException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Exception thrown if a query encountered an error
+ */
+class QueryException extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/StatementException.php b/library/Icinga/Exception/StatementException.php
new file mode 100644
index 0000000..7501c86
--- /dev/null
+++ b/library/Icinga/Exception/StatementException.php
@@ -0,0 +1,8 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+class StatementException extends IcingaException
+{
+}
diff --git a/library/Icinga/Exception/SystemPermissionException.php b/library/Icinga/Exception/SystemPermissionException.php
new file mode 100644
index 0000000..5651169
--- /dev/null
+++ b/library/Icinga/Exception/SystemPermissionException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Exception;
+
+/**
+ * Handle problems according to file system permissions
+ */
+class SystemPermissionException extends IcingaException
+{
+}
diff --git a/library/Icinga/File/Csv.php b/library/Icinga/File/Csv.php
new file mode 100644
index 0000000..56ee233
--- /dev/null
+++ b/library/Icinga/File/Csv.php
@@ -0,0 +1,47 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File;
+
+use Traversable;
+
+class Csv
+{
+ protected $query;
+
+ protected function __construct()
+ {
+ }
+
+ public static function fromQuery(Traversable $query)
+ {
+ $csv = new static();
+ $csv->query = $query;
+ return $csv;
+ }
+
+ public function dump()
+ {
+ header('Content-type: text/csv');
+ echo (string) $this;
+ }
+
+ public function __toString()
+ {
+ $first = true;
+ $csv = '';
+ foreach ($this->query as $row) {
+ if ($first) {
+ $csv .= implode(',', array_keys((array) $row)) . "\r\n";
+ $first = false;
+ }
+ $out = array();
+ foreach ($row as & $val) {
+ $out[] = '"' . str_replace('"', '""', $val) . '"';
+ }
+ $csv .= implode(',', $out) . "\r\n";
+ }
+
+ return $csv;
+ }
+}
diff --git a/library/Icinga/File/Ini/Dom/Comment.php b/library/Icinga/File/Ini/Dom/Comment.php
new file mode 100644
index 0000000..c202d0f
--- /dev/null
+++ b/library/Icinga/File/Ini/Dom/Comment.php
@@ -0,0 +1,37 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Ini\Dom;
+
+/**
+ * A single comment-line in an INI file
+ */
+class Comment
+{
+ /**
+ * The comment text
+ *
+ * @var string
+ */
+ protected $content;
+
+ /**
+ * Set the text content of this comment
+ *
+ * @param $content
+ */
+ public function setContent($content)
+ {
+ $this->content = $content;
+ }
+
+ /**
+ * Render this comment into INI markup
+ *
+ * @return string
+ */
+ public function render()
+ {
+ return ';' . $this->content;
+ }
+}
diff --git a/library/Icinga/File/Ini/Dom/Directive.php b/library/Icinga/File/Ini/Dom/Directive.php
new file mode 100644
index 0000000..4279a5f
--- /dev/null
+++ b/library/Icinga/File/Ini/Dom/Directive.php
@@ -0,0 +1,166 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Ini\Dom;
+
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * A key value pair in a Section
+ */
+class Directive
+{
+ /**
+ * The value of this configuration directive
+ *
+ * @var string
+ */
+ protected $key;
+
+ /**
+ * The immutable name of this configuration directive
+ *
+ * @var string
+ */
+ protected $value;
+
+ /**
+ * Comments added one line before this directive
+ *
+ * @var Comment[] The comment lines
+ */
+ protected $commentsPre = null;
+
+ /**
+ * Comment added at the end of the same line
+ *
+ * @var Comment
+ */
+ protected $commentPost = null;
+
+ /**
+ * @param string $key The name of this configuration directive
+ *
+ * @throws ConfigurationError
+ */
+ public function __construct($key)
+ {
+ $this->key = trim($key);
+ if (strlen($this->key) < 1) {
+ throw new ConfigurationError(sprintf('Ini error: empty directive key.'));
+ }
+ }
+
+ /**
+ * Return the name of this directive
+ *
+ * @return string
+ */
+ public function getKey()
+ {
+ return $this->key;
+ }
+
+ /**
+ * Return the value of this configuration directive
+ *
+ * @return string
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Set the value of this configuration directive
+ *
+ * @param string $value
+ */
+ public function setValue($value)
+ {
+ $this->value = trim($value);
+ }
+
+ /**
+ * Set the comments to be rendered on the line before this directive
+ *
+ * @param Comment[] $comments
+ */
+ public function setCommentsPre(array $comments)
+ {
+ $this->commentsPre = $comments;
+ }
+
+ /**
+ * Return the comments to be rendered on the line before this directive
+ *
+ * @return Comment[]
+ */
+ public function getCommentsPre()
+ {
+ return $this->commentsPre;
+ }
+
+ /**
+ * Set the comment rendered on the same line of this directive
+ *
+ * @param Comment $comment
+ */
+ public function setCommentPost(Comment $comment)
+ {
+ $this->commentPost = $comment;
+ }
+
+ /**
+ * Render this configuration directive into INI markup
+ *
+ * @return string
+ */
+ public function render()
+ {
+ $str = '';
+ if (! empty($this->commentsPre)) {
+ $comments = array();
+ foreach ($this->commentsPre as $comment) {
+ $comments[] = $comment->render();
+ }
+ $str = implode(PHP_EOL, $comments) . PHP_EOL;
+ }
+ $str .= sprintf('%s = "%s"', $this->sanitizeKey($this->key), $this->sanitizeValue($this->value));
+ if (isset($this->commentPost)) {
+ $str .= ' ' . $this->commentPost->render();
+ }
+ return $str;
+ }
+
+ /**
+ * Assure that the given identifier contains no newlines and pending or trailing whitespaces
+ *
+ * @param $str The string to sanitize
+ *
+ * @return string
+ */
+ protected function sanitizeKey($str)
+ {
+ return trim(str_replace(PHP_EOL, ' ', $str));
+ }
+
+ /**
+ * Escape the significant characters in directive values, normalize line breaks and assure that
+ * the character contains no linebreaks
+ *
+ * @param $str The string to sanitize
+ *
+ * @return mixed|string
+ */
+ protected function sanitizeValue($str)
+ {
+ $str = trim($str);
+ $str = str_replace('\\', '\\\\', $str);
+ $str = str_replace('"', '\"', $str);
+ $str = str_replace("\r", '\r', $str);
+ $str = str_replace("\n", '\n', $str);
+
+ return $str;
+ }
+}
diff --git a/library/Icinga/File/Ini/Dom/Document.php b/library/Icinga/File/Ini/Dom/Document.php
new file mode 100644
index 0000000..f38f33e
--- /dev/null
+++ b/library/Icinga/File/Ini/Dom/Document.php
@@ -0,0 +1,132 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Ini\Dom;
+
+class Document
+{
+ /**
+ * The sections of this INI file
+ *
+ * @var Section[]
+ */
+ protected $sections = array();
+
+ /**
+ * The comemnts at file end that belong to no particular section
+ *
+ * @var Comment[]
+ */
+ protected $commentsDangling;
+
+ /**
+ * Append a section to the end of this INI file
+ *
+ * @param Section $section
+ */
+ public function addSection(Section $section)
+ {
+ $this->sections[$section->getName()] = $section;
+ }
+
+ /**
+ * Return whether this INI file has the section with the given key
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasSection($name)
+ {
+ return isset($this->sections[trim($name)]);
+ }
+
+ /**
+ * Return the section with the given name
+ *
+ * @param string $name
+ *
+ * @return Section
+ */
+ public function getSection($name)
+ {
+ return $this->sections[trim($name)];
+ }
+
+ /**
+ * Set the section with the given name
+ *
+ * @param string $name
+ * @param Section $section
+ *
+ * @return Section
+ */
+ public function setSection($name, Section $section)
+ {
+ return $this->sections[trim($name)] = $section;
+ }
+
+ /**
+ * Remove the section with the given name
+ *
+ * @param string $name
+ */
+ public function removeSection($name)
+ {
+ unset($this->sections[trim($name)]);
+ }
+
+ /**
+ * Set the dangling comments at file end that belong to no particular directive
+ *
+ * @param Comment[] $comments
+ */
+ public function setCommentsDangling(array $comments)
+ {
+ $this->commentsDangling = $comments;
+ }
+
+ /**
+ * Get the dangling comments at file end that belong to no particular directive
+ *
+ * @return array
+ */
+ public function getCommentsDangling()
+ {
+ return $this->commentsDangling;
+ }
+
+ /**
+ * Render this document into the corresponding INI markup
+ *
+ * @return string
+ */
+ public function render()
+ {
+ $sections = array();
+ foreach ($this->sections as $section) {
+ $sections []= $section->render();
+ }
+ $str = implode(PHP_EOL, $sections);
+ if (! empty($this->commentsDangling)) {
+ foreach ($this->commentsDangling as $comment) {
+ $str .= PHP_EOL . $comment->render();
+ }
+ }
+ return $str;
+ }
+
+ /**
+ * Convert $this to an array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $a = array();
+ foreach ($this->sections as $section) {
+ $a[$section->getName()] = $section->toArray();
+ }
+ return $a;
+ }
+}
diff --git a/library/Icinga/File/Ini/Dom/Section.php b/library/Icinga/File/Ini/Dom/Section.php
new file mode 100644
index 0000000..5fac5ea
--- /dev/null
+++ b/library/Icinga/File/Ini/Dom/Section.php
@@ -0,0 +1,190 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Ini\Dom;
+
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * A section in an INI file
+ */
+class Section
+{
+ /**
+ * The immutable name of this section
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * All configuration directives of this section
+ *
+ * @var Directive[]
+ */
+ protected $directives = array();
+
+ /**
+ * Comments added one line before this section
+ *
+ * @var Comment[]
+ */
+ protected $commentsPre;
+
+ /**
+ * Comment added at the end of the same line
+ *
+ * @var Comment
+ */
+ protected $commentPost;
+
+ /**
+ * @param string $name The immutable name of this section
+ *
+ * @throws ConfigurationError When the section name is empty or contains brackets
+ */
+ public function __construct($name)
+ {
+ $this->name = trim($name);
+ if (strlen($this->name) < 1) {
+ throw new ConfigurationError('Ini file error: empty section identifier');
+ } elseif (strpos($name, '[') !== false || strpos($name, ']') !== false) {
+ throw new ConfigurationError(
+ 'Ini file error: Section name "%s" must not contain any brackets ([, ])',
+ $name
+ );
+ }
+ }
+
+ /**
+ * Append a directive to the end of this section
+ *
+ * @param Directive $directive The directive to append
+ */
+ public function addDirective(Directive $directive)
+ {
+ $this->directives[$directive->getKey()] = $directive;
+ }
+
+ /**
+ * Remove the directive with the given name
+ *
+ * @param string $key They name of the directive to remove
+ */
+ public function removeDirective($key)
+ {
+ unset($this->directives[$key]);
+ }
+
+ /**
+ * Return whether this section has a directive with the given key
+ *
+ * @param string $key The name of the directive
+ *
+ * @return bool
+ */
+ public function hasDirective($key)
+ {
+ return isset($this->directives[$key]);
+ }
+
+ /**
+ * Get the directive with the given key
+ *
+ * @param $key string
+ *
+ * @return Directive
+ */
+ public function getDirective($key)
+ {
+ return $this->directives[$key];
+ }
+
+ /**
+ * Return the name of this section
+ *
+ * @return string The name
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the comments to be rendered on the line before this section
+ *
+ * @param Comment[] $comments
+ */
+ public function setCommentsPre(array $comments)
+ {
+ $this->commentsPre = $comments;
+ }
+
+ /**
+ * Set the comment rendered on the same line of this section
+ *
+ * @param Comment $comment
+ */
+ public function setCommentPost(Comment $comment)
+ {
+ $this->commentPost = $comment;
+ }
+
+ /**
+ * Render this section into INI markup
+ *
+ * @return string
+ */
+ public function render()
+ {
+ $dirs = '';
+ $i = 0;
+ foreach ($this->directives as $directive) {
+ $comments = $directive->getCommentsPre();
+ $dirs .= (($i++ > 0 && ! empty($comments)) ? PHP_EOL : '')
+ . $directive->render() . PHP_EOL;
+ }
+ $cms = '';
+ if (! empty($this->commentsPre)) {
+ foreach ($this->commentsPre as $comment) {
+ $comments[] = $comment->render();
+ }
+ $cms = implode(PHP_EOL, $comments) . PHP_EOL;
+ }
+ $post = '';
+ if (isset($this->commentPost)) {
+ $post = ' ' . $this->commentPost->render();
+ }
+ return $cms . sprintf('[%s]', $this->sanitize($this->name)) . $post . PHP_EOL . $dirs;
+ }
+
+ /**
+ * Escape the significant characters in sections and normalize line breaks
+ *
+ * @param $str The string to sanitize
+ *
+ * @return mixed
+ */
+ protected function sanitize($str)
+ {
+ $str = trim($str);
+ $str = str_replace('\\', '\\\\', $str);
+ $str = str_replace('"', '\\"', $str);
+ $str = str_replace(';', '\\;', $str);
+ return str_replace(PHP_EOL, ' ', $str);
+ }
+
+ /**
+ * Convert $this to an array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $a = array();
+ foreach ($this->directives as $directive) {
+ $a[$directive->getKey()] = $directive->getValue();
+ }
+ return $a;
+ }
+}
diff --git a/library/Icinga/File/Ini/IniParser.php b/library/Icinga/File/Ini/IniParser.php
new file mode 100644
index 0000000..279aa45
--- /dev/null
+++ b/library/Icinga/File/Ini/IniParser.php
@@ -0,0 +1,310 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Ini;
+
+use ErrorException;
+use Icinga\File\Ini\Dom\Section;
+use Icinga\File\Ini\Dom\Comment;
+use Icinga\File\Ini\Dom\Document;
+use Icinga\File\Ini\Dom\Directive;
+use Icinga\Application\Logger;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Application\Config;
+
+class IniParser
+{
+ const LINE_START = 0;
+ const SECTION = 1;
+ const ESCAPE = 2;
+ const DIRECTIVE_KEY = 4;
+ const DIRECTIVE_VALUE_START = 5;
+ const DIRECTIVE_VALUE = 6;
+ const DIRECTIVE_VALUE_QUOTED = 7;
+ const COMMENT = 8;
+ const COMMENT_END = 9;
+ const LINE_END = 10;
+
+ /**
+ * Cancel the parsing with an error
+ *
+ * @param $message The error description
+ * @param $line The line in which the error occured
+ *
+ * @throws ConfigurationError
+ */
+ private static function throwParseError($message, $line)
+ {
+ throw new ConfigurationError(sprintf('Ini parser error: %s. (l. %d)', $message, $line));
+ }
+
+ /**
+ * Read the ini file contained in a string and return a mutable DOM that can be used
+ * to change the content of an INI file.
+ *
+ * @param $str A string containing the whole ini file
+ *
+ * @return Document The mutable DOM object.
+ * @throws ConfigurationError In case the file is not parseable
+ */
+ public static function parseIni($str)
+ {
+ $doc = new Document();
+ $sec = null;
+ $dir = null;
+ $coms = array();
+ $state = self::LINE_START;
+ $escaping = null;
+ $token = '';
+ $line = 0;
+
+ for ($i = 0; $i < strlen($str); $i++) {
+ $s = $str[$i];
+ switch ($state) {
+ case self::LINE_START:
+ if (ctype_space($s)) {
+ continue 2;
+ }
+ switch ($s) {
+ case '[':
+ $state = self::SECTION;
+ break;
+ case ';':
+ $state = self::COMMENT;
+ break;
+ default:
+ $state = self::DIRECTIVE_KEY;
+ $token = $s;
+ break;
+ }
+ break;
+
+ case self::ESCAPE:
+ $token .= $s;
+ $state = $escaping;
+ $escaping = null;
+ break;
+
+ case self::SECTION:
+ if ($s === "\n") {
+ self::throwParseError('Unterminated SECTION', $line);
+ } elseif ($s === '\\') {
+ $state = self::ESCAPE;
+ $escaping = self::SECTION;
+ } elseif ($s !== ']') {
+ $token .= $s;
+ } else {
+ $sec = new Section($token);
+ $sec->setCommentsPre($coms);
+ $doc->addSection($sec);
+ $dir = null;
+ $coms = array();
+
+ $state = self::LINE_END;
+ $token = '';
+ }
+ break;
+
+ case self::DIRECTIVE_KEY:
+ if ($s !== '=') {
+ $token .= $s;
+ } else {
+ $dir = new Directive($token);
+ $dir->setCommentsPre($coms);
+ if (isset($sec)) {
+ $sec->addDirective($dir);
+ } else {
+ Logger::warning(sprintf(
+ 'Ini parser warning: section-less directive "%s" ignored. (l. %d)',
+ $token,
+ $line
+ ));
+ }
+
+ $coms = array();
+ $state = self::DIRECTIVE_VALUE_START;
+ $token = '';
+ }
+ break;
+
+ case self::DIRECTIVE_VALUE_START:
+ if (ctype_space($s)) {
+ continue 2;
+ } elseif ($s === '"') {
+ $state = self::DIRECTIVE_VALUE_QUOTED;
+ } else {
+ $state = self::DIRECTIVE_VALUE;
+ $token = $s;
+ }
+ break;
+
+ case self::DIRECTIVE_VALUE:
+ /*
+ Escaping non-quoted values is not supported by php_parse_ini, it might
+ be reasonable to include in case we are switching completely our own
+ parser implementation
+ */
+ if ($s === "\n" || $s === ";") {
+ $dir->setValue($token);
+ $token = '';
+
+ if ($s === "\n") {
+ $state = self::LINE_START;
+ $line ++;
+ } elseif ($s === ';') {
+ $state = self::COMMENT;
+ }
+ } else {
+ $token .= $s;
+ }
+ break;
+
+ case self::DIRECTIVE_VALUE_QUOTED:
+ if ($s === '\\') {
+ $state = self::ESCAPE;
+ $escaping = self::DIRECTIVE_VALUE_QUOTED;
+ } elseif ($s !== '"') {
+ $token .= $s;
+ } else {
+ $dir->setValue($token);
+ $token = '';
+ $state = self::LINE_END;
+ }
+ break;
+
+ case self::COMMENT:
+ case self::COMMENT_END:
+ if ($s !== "\n") {
+ $token .= $s;
+ } else {
+ $com = new Comment();
+ $com->setContent($token);
+ $token = '';
+
+ // Comments at the line end belong to the current line's directive or section. Comments
+ // on empty lines belong to the next directive that shows up.
+ if ($state === self::COMMENT_END) {
+ if (isset($dir)) {
+ $dir->setCommentPost($com);
+ } else {
+ $sec->setCommentPost($com);
+ }
+ } else {
+ $coms[] = $com;
+ }
+ $state = self::LINE_START;
+ $line ++;
+ }
+ break;
+
+ case self::LINE_END:
+ if ($s === "\n") {
+ $state = self::LINE_START;
+ $line ++;
+ } elseif ($s === ';') {
+ $state = self::COMMENT_END;
+ }
+ break;
+ }
+ }
+
+ // process the last token
+ switch ($state) {
+ case self::COMMENT:
+ case self::COMMENT_END:
+ $com = new Comment();
+ $com->setContent($token);
+ if ($state === self::COMMENT_END) {
+ if (isset($dir)) {
+ $dir->setCommentPost($com);
+ } else {
+ $sec->setCommentPost($com);
+ }
+ } else {
+ $coms[] = $com;
+ }
+ break;
+
+ case self::DIRECTIVE_VALUE:
+ $dir->setValue($token);
+ $sec->addDirective($dir);
+ break;
+
+ case self::ESCAPE:
+ case self::DIRECTIVE_VALUE_QUOTED:
+ case self::DIRECTIVE_KEY:
+ case self::SECTION:
+ self::throwParseError('File ended in unterminated state ' . $state, $line);
+ }
+ if (! empty($coms)) {
+ $doc->setCommentsDangling($coms);
+ }
+ return $doc;
+ }
+
+ /**
+ * Read the ini file and parse it with ::parseIni()
+ *
+ * @param string $file The ini file to read
+ *
+ * @return Config
+ * @throws NotReadableError When the file cannot be read
+ */
+ public static function parseIniFile($file)
+ {
+ if (($path = realpath($file)) === false) {
+ throw new NotReadableError('Couldn\'t compute the absolute path of `%s\'', $file);
+ }
+
+ if (($content = file_get_contents($path)) === false) {
+ throw new NotReadableError('Couldn\'t read the file `%s\'', $path);
+ }
+
+ try {
+ $configArray = parse_ini_string($content, true, INI_SCANNER_RAW);
+ } catch (ErrorException $e) {
+ throw new ConfigurationError('Couldn\'t parse the INI file `%s\'', $path, $e);
+ }
+
+ $unescaped = array();
+ foreach ($configArray as $section => $options) {
+ $unescaped[self::unescapeSectionName($section)] = array_map([__CLASS__, 'unescapeOptionValue'], $options);
+ }
+
+ return Config::fromArray($unescaped)->setConfigFile($file);
+ }
+
+ /**
+ * Unescape significant characters in the given section name
+ *
+ * @param string $str
+ *
+ * @return string
+ */
+ protected static function unescapeSectionName($str)
+ {
+ $str = str_replace('\"', '"', $str);
+ $str = str_replace('\;', ';', $str);
+
+ return str_replace('\\\\', '\\', $str);
+ }
+
+ /**
+ * Unescape significant characters in the given option value
+ *
+ * @param string $str
+ *
+ * @return string
+ */
+ protected static function unescapeOptionValue($str)
+ {
+ $str = str_replace('\n', "\n", $str);
+ $str = str_replace('\r', "\r", $str);
+ $str = str_replace('\"', '"', $str);
+ $str = str_replace('\\\\', '\\', $str);
+
+ // This replacement is a work-around for PHP bug #76965. Fixed with versions 7.1.24, 7.2.12 and 7.3.0.
+ return preg_replace('~^([\'"])(.*?)\1\s+$~', '$2', $str);
+ }
+}
diff --git a/library/Icinga/File/Ini/IniWriter.php b/library/Icinga/File/Ini/IniWriter.php
new file mode 100644
index 0000000..1f470b0
--- /dev/null
+++ b/library/Icinga/File/Ini/IniWriter.php
@@ -0,0 +1,205 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Ini;
+
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ProgrammingError;
+use Icinga\File\Ini\Dom\Directive;
+use Icinga\File\Ini\Dom\Document;
+use Icinga\File\Ini\Dom\Section;
+use Zend_Config_Exception;
+use Icinga\Application\Config;
+
+/**
+ * A INI file adapter that respects the file structure and the comments of already existing ini files
+ */
+class IniWriter
+{
+ /**
+ * Stores the options
+ *
+ * @var array
+ */
+ protected $options;
+
+ /**
+ * The configuration object to write
+ *
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * The mode to set on new files
+ *
+ * @var int
+ */
+ protected $fileMode;
+
+ /**
+ * The path to write to
+ *
+ * @var string
+ */
+ protected $filename;
+
+ /**
+ * Create a new INI writer
+ *
+ * @param Config $config The configuration to write
+ * @param string $filename The file name to write to
+ * @param int $filemode Octal file persmissions
+ *
+ * @link http://framework.zend.com/apidoc/1.12/files/Config.Writer.html#\Zend_Config_Writer
+ */
+ public function __construct(Config $config, $filename, $filemode = 0660, $options = array())
+ {
+ $this->config = $config;
+ $this->filename = $filename;
+ $this->fileMode = $filemode;
+ $this->options = $options;
+ }
+
+ /**
+ * Render the Zend_Config into a config filestring
+ *
+ * @return string
+ */
+ public function render()
+ {
+ if (file_exists($this->filename)) {
+ $oldconfig = Config::fromIni($this->filename);
+ $content = trim(file_get_contents($this->filename));
+ } else {
+ $oldconfig = Config::fromArray(array());
+ $content = '';
+ }
+ $doc = IniParser::parseIni($content);
+ $this->diffPropertyUpdates($this->config, $doc);
+ $this->diffPropertyDeletions($oldconfig, $this->config, $doc);
+ $doc = $this->updateSectionOrder($this->config, $doc);
+ return $doc->render();
+ }
+
+ /**
+ * Write configuration to file and set file mode in case it does not exist yet
+ *
+ * @param string $filename
+ * @param bool $exclusiveLock
+ *
+ * @throws Zend_Config_Exception
+ */
+ public function write($filename = null, $exclusiveLock = false)
+ {
+ $filePath = isset($filename) ? $filename : $this->filename;
+ $setMode = false === file_exists($filePath);
+
+ if (file_put_contents($filePath, $this->render(), $exclusiveLock ? LOCK_EX : 0) === false) {
+ throw new Zend_Config_Exception('Could not write to file "' . $filePath . '"');
+ }
+
+ if ($setMode) {
+ // file was newly created
+ $mode = $this->fileMode;
+ if (is_int($this->fileMode) && false === @chmod($filePath, $this->fileMode)) {
+ throw new Zend_Config_Exception(sprintf('Failed to set file mode "%o" on file "%s"', $mode, $filePath));
+ }
+ }
+ }
+
+ /**
+ * Update the order of the sections in the ini file to match the order of the new config
+ *
+ * @return Document A new document with the changed section order applied
+ */
+ protected function updateSectionOrder(Config $newconfig, Document $oldDoc)
+ {
+ $doc = new Document();
+ $dangling = $oldDoc->getCommentsDangling();
+ if (! empty($dangling)) {
+ $doc->setCommentsDangling($dangling);
+ }
+ foreach ($newconfig->toArray() as $section => $directives) {
+ $doc->addSection($oldDoc->getSection($section));
+ }
+ return $doc;
+ }
+
+ /**
+ * Search for created and updated properties and use the editor to create or update these entries
+ *
+ * @param Config $newconfig The config representing the state after the change
+ * @param Document $doc
+ *
+ * @throws ProgrammingError
+ */
+ protected function diffPropertyUpdates(Config $newconfig, Document $doc)
+ {
+ foreach ($newconfig->toArray() as $section => $directives) {
+ if (! is_array($directives)) {
+ Logger::warning('Section-less property ' . (string)$directives . ' was ignored.');
+ continue;
+ }
+ if (!$doc->hasSection($section)) {
+ $domSection = new Section($section);
+ $doc->addSection($domSection);
+ } else {
+ $domSection = $doc->getSection($section);
+ }
+ foreach ($directives as $key => $value) {
+ if ($value === null) {
+ continue;
+ }
+
+ if ($value instanceof ConfigObject) {
+ throw new ProgrammingError('Cannot diff recursive configs');
+ }
+ if ($domSection->hasDirective($key)) {
+ $domSection->getDirective($key)->setValue($value);
+ } else {
+ $dir = new Directive($key);
+ $dir->setValue($value);
+ $domSection->addDirective($dir);
+ }
+ }
+ }
+ }
+
+ /**
+ * Search for deleted properties and use the editor to delete these entries
+ *
+ * @param Config $oldconfig The config representing the state before the change
+ * @param Config $newconfig The config representing the state after the change
+ * @param Document $doc
+ *
+ * @throws ProgrammingError
+ */
+ protected function diffPropertyDeletions(Config $oldconfig, Config $newconfig, Document $doc)
+ {
+ // Iterate over all properties in the old configuration file and remove those that don't
+ // exist in the new config
+ foreach ($oldconfig->toArray() as $section => $directives) {
+ if (! is_array($directives)) {
+ Logger::warning('Section-less property ' . (string)$directives . ' was ignored.');
+ continue;
+ }
+
+ if ($newconfig->hasSection($section)) {
+ $newSection = $newconfig->getSection($section);
+ $oldDomSection = $doc->getSection($section);
+ foreach ($directives as $key => $value) {
+ if ($value instanceof ConfigObject) {
+ throw new ProgrammingError('Cannot diff recursive configs');
+ }
+ if (null === $newSection->get($key) && $oldDomSection->hasDirective($key)) {
+ $oldDomSection->removeDirective($key);
+ }
+ }
+ } else {
+ $doc->removeSection($section);
+ }
+ }
+ }
+}
diff --git a/library/Icinga/File/Pdf.php b/library/Icinga/File/Pdf.php
new file mode 100644
index 0000000..1b78424
--- /dev/null
+++ b/library/Icinga/File/Pdf.php
@@ -0,0 +1,81 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File;
+
+use Dompdf\Dompdf;
+use Dompdf\Options;
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Util\Environment;
+use Icinga\Web\Hook;
+use Icinga\Web\Url;
+
+class Pdf
+{
+ protected function assertNoHeadersSent()
+ {
+ if (headers_sent()) {
+ throw new ProgrammingError(
+ 'Could not send pdf-response, content already written to output.'
+ );
+ }
+ }
+
+ public function renderControllerAction($controller)
+ {
+ $this->assertNoHeadersSent();
+
+ Environment::raiseMemoryLimit('512M');
+ Environment::raiseExecutionTime(300);
+
+ $viewRenderer = $controller->getHelper('viewRenderer');
+ $viewRenderer->postDispatch();
+
+ $layoutHelper = $controller->getHelper('layout');
+ $oldLayout = $layoutHelper->getLayout();
+ $layout = $layoutHelper->setLayout('pdf');
+
+ $layout->content = $controller->getResponse();
+ $html = $layout->render();
+
+ // Restore previous layout and reset content, to properly show errors
+ $controller->getResponse()->clearBody($viewRenderer->getResponseSegment());
+ $layoutHelper->setLayout($oldLayout);
+
+ $imgDir = Url::fromPath('img');
+ $html = preg_replace(
+ '~src="' . $imgDir . '/~',
+ 'src="' . Icinga::app()->getBootstrapDirectory() . '/img/',
+ $html
+ );
+
+ $request = $controller->getRequest();
+
+ if (Hook::has('Pdfexport')) {
+ $pdfexport = Hook::first('Pdfexport');
+ $pdfexport->streamPdfFromHtml($html, sprintf(
+ '%s-%s-%d',
+ $request->getControllerName(),
+ $request->getActionName(),
+ time()
+ ));
+
+ return;
+ }
+
+ $options = new Options();
+ $options->set('defaultPaperSize', 'A4');
+ $dompdf = new Dompdf($options);
+ $dompdf->loadHtml($html);
+ $dompdf->render();
+ $dompdf->stream(
+ sprintf(
+ '%s-%s-%d',
+ $request->getControllerName(),
+ $request->getActionName(),
+ time()
+ )
+ );
+ }
+}
diff --git a/library/Icinga/File/Storage/LocalFileStorage.php b/library/Icinga/File/Storage/LocalFileStorage.php
new file mode 100644
index 0000000..e1ed641
--- /dev/null
+++ b/library/Icinga/File/Storage/LocalFileStorage.php
@@ -0,0 +1,164 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Storage;
+
+use ErrorException;
+use Icinga\Exception\AlreadyExistsException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\NotWritableError;
+use InvalidArgumentException;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use Traversable;
+use UnexpectedValueException;
+
+/**
+ * Stores files in the local file system
+ */
+class LocalFileStorage implements StorageInterface
+{
+ /**
+ * The root directory of this storage
+ *
+ * @var string
+ */
+ protected $baseDir;
+
+ /**
+ * Constructor
+ *
+ * @param string $baseDir The root directory of this storage
+ */
+ public function __construct($baseDir)
+ {
+ $this->baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR);
+ }
+
+ public function getIterator(): Traversable
+ {
+ try {
+ return new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator(
+ $this->baseDir,
+ RecursiveDirectoryIterator::CURRENT_AS_FILEINFO
+ | RecursiveDirectoryIterator::KEY_AS_PATHNAME
+ | RecursiveDirectoryIterator::SKIP_DOTS
+ )
+ );
+ } catch (UnexpectedValueException $e) {
+ throw new NotReadableError('Couldn\'t read the directory "%s": %s', $this->baseDir, $e);
+ }
+ }
+
+ public function has($path)
+ {
+ return is_file($this->resolvePath($path));
+ }
+
+ public function create($path, $content)
+ {
+ $resolvedPath = $this->resolvePath($path);
+
+ $this->ensureDir(dirname($resolvedPath));
+
+ try {
+ $stream = fopen($resolvedPath, 'x');
+ } catch (ErrorException $e) {
+ throw new AlreadyExistsException('Couldn\'t create the file "%s": %s', $path, $e);
+ }
+
+ try {
+ fclose($stream);
+ chmod($resolvedPath, 0664);
+ file_put_contents($resolvedPath, $content);
+ } catch (ErrorException $e) {
+ throw new NotWritableError('Couldn\'t create the file "%s": %s', $path, $e);
+ }
+
+ return $this;
+ }
+
+ public function read($path)
+ {
+ $resolvedPath = $this->resolvePath($path, true);
+
+ try {
+ return file_get_contents($resolvedPath);
+ } catch (ErrorException $e) {
+ throw new NotReadableError('Couldn\'t read the file "%s": %s', $path, $e);
+ }
+ }
+
+ public function update($path, $content)
+ {
+ $resolvedPath = $this->resolvePath($path, true);
+
+ try {
+ file_put_contents($resolvedPath, $content);
+ } catch (ErrorException $e) {
+ throw new NotWritableError('Couldn\'t update the file "%s": %s', $path, $e);
+ }
+
+ return $this;
+ }
+
+ public function delete($path)
+ {
+ $resolvedPath = $this->resolvePath($path, true);
+
+ try {
+ unlink($resolvedPath);
+ } catch (ErrorException $e) {
+ throw new NotWritableError('Couldn\'t delete the file "%s": %s', $path, $e);
+ }
+
+ return $this;
+ }
+
+ public function resolvePath($path, $assertExistence = false)
+ {
+ if ($assertExistence && ! $this->has($path)) {
+ throw new NotFoundError('No such file: "%s"', $path);
+ }
+
+ $steps = preg_split('~/~', $path, -1, PREG_SPLIT_NO_EMPTY);
+ for ($i = 0; $i < count($steps);) {
+ if ($steps[$i] === '.') {
+ array_splice($steps, $i, 1);
+ } elseif ($steps[$i] === '..' && $i > 0 && $steps[$i - 1] !== '..') {
+ array_splice($steps, $i - 1, 2);
+ --$i;
+ } else {
+ ++$i;
+ }
+ }
+
+ if ($steps[0] === '..') {
+ throw new InvalidArgumentException('Paths above the base directory are not allowed');
+ }
+
+ return $this->baseDir . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $steps);
+ }
+
+ /**
+ * Ensure that the given directory exists
+ *
+ * @param string $dir
+ *
+ * @throws NotWritableError
+ */
+ protected function ensureDir($dir)
+ {
+ if (! is_dir($dir)) {
+ $this->ensureDir(dirname($dir));
+
+ try {
+ mkdir($dir, 02770);
+ } catch (ErrorException $e) {
+ throw new NotWritableError('Couldn\'t create the directory "%s": %s', $dir, $e);
+ }
+ }
+ }
+}
diff --git a/library/Icinga/File/Storage/StorageInterface.php b/library/Icinga/File/Storage/StorageInterface.php
new file mode 100644
index 0000000..f416b00
--- /dev/null
+++ b/library/Icinga/File/Storage/StorageInterface.php
@@ -0,0 +1,94 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Storage;
+
+use Icinga\Exception\AlreadyExistsException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\NotWritableError;
+use IteratorAggregate;
+use Traversable;
+
+interface StorageInterface extends IteratorAggregate
+{
+ /**
+ * Iterate over all existing files' paths
+ *
+ * @return Traversable
+ *
+ * @throws NotReadableError If the file list can't be read
+ */
+ public function getIterator(): Traversable;
+
+ /**
+ * Return whether the given file exists
+ *
+ * @param string $path
+ *
+ * @return bool
+ */
+ public function has($path);
+
+ /**
+ * Create the given file with the given content
+ *
+ * @param string $path
+ * @param mixed $content
+ *
+ * @return $this
+ *
+ * @throws AlreadyExistsException If the file already exists
+ * @throws NotWritableError If the file can't be written to
+ */
+ public function create($path, $content);
+
+ /**
+ * Load the content of the given file
+ *
+ * @param string $path
+ *
+ * @return mixed
+ *
+ * @throws NotFoundError If the file can't be found
+ * @throws NotReadableError If the file can't be read
+ */
+ public function read($path);
+
+ /**
+ * Overwrite the given file with the given content
+ *
+ * @param string $path
+ * @param mixed $content
+ *
+ * @return $this
+ *
+ * @throws NotFoundError If the file can't be found
+ * @throws NotWritableError If the file can't be written to
+ */
+ public function update($path, $content);
+
+ /**
+ * Delete the given file
+ *
+ * @param string $path
+ *
+ * @return $this
+ *
+ * @throws NotFoundError If the file can't be found
+ * @throws NotWritableError If the file can't be deleted
+ */
+ public function delete($path);
+
+ /**
+ * Get the absolute path to the given file
+ *
+ * @param string $path
+ * @param bool $assertExistence Whether to require that the given file exists
+ *
+ * @return string
+ *
+ * @throws NotFoundError If the file has to exist, but can't be found
+ */
+ public function resolvePath($path, $assertExistence = false);
+}
diff --git a/library/Icinga/File/Storage/TemporaryLocalFileStorage.php b/library/Icinga/File/Storage/TemporaryLocalFileStorage.php
new file mode 100644
index 0000000..faf91f5
--- /dev/null
+++ b/library/Icinga/File/Storage/TemporaryLocalFileStorage.php
@@ -0,0 +1,59 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\File\Storage;
+
+use ErrorException;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+
+/**
+ * Stores files in a temporary directory
+ */
+class TemporaryLocalFileStorage extends LocalFileStorage
+{
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid();
+ mkdir($path, 0700);
+
+ parent::__construct($path);
+ }
+
+ /**
+ * Destructor
+ */
+ public function __destruct()
+ {
+ // Some classes may have cleaned up the tmp file, so we need to check this
+ // beforehand to prevent an unexpected crash.
+ if (! @realpath($this->baseDir)) {
+ return;
+ }
+
+ $directoryIterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator(
+ $this->baseDir,
+ RecursiveDirectoryIterator::CURRENT_AS_FILEINFO
+ | RecursiveDirectoryIterator::KEY_AS_PATHNAME
+ | RecursiveDirectoryIterator::SKIP_DOTS
+ ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($directoryIterator as $path => $entry) {
+ /** @var \SplFileInfo $entry */
+
+ if ($entry->isDir() && ! $entry->isLink()) {
+ rmdir($path);
+ } else {
+ unlink($path);
+ }
+ }
+
+ rmdir($this->baseDir);
+ }
+}
diff --git a/library/Icinga/Legacy/DashboardConfig.php b/library/Icinga/Legacy/DashboardConfig.php
new file mode 100644
index 0000000..3fb5c2f
--- /dev/null
+++ b/library/Icinga/Legacy/DashboardConfig.php
@@ -0,0 +1,137 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Legacy;
+
+use Icinga\Application\Config;
+use Icinga\User;
+use Icinga\Web\Navigation\DashboardPane;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Navigation\NavigationItem;
+
+/**
+ * Legacy dashboard config class for case insensitive interpretation of dashboard config files
+ *
+ * Before 2.2, the username part in dashboard config files was not lowered.
+ *
+ * @deprecated(el): Remove. TBD.
+ */
+class DashboardConfig extends Config
+{
+ /**
+ * User
+ *
+ * @var User
+ */
+ protected $user;
+
+ /**
+ * Get the user
+ *
+ * @return User
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Set the user
+ *
+ * @param User $user
+ *
+ * @return $this
+ */
+ public function setUser($user)
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+
+ /**
+ * List all dashboard configuration files that match the given user
+ *
+ * @param User $user
+ *
+ * @return string[]
+ */
+ public static function listConfigFilesForUser(User $user)
+ {
+ $files = array();
+ $dashboards = static::resolvePath('dashboards');
+ if ($handle = @opendir($dashboards)) {
+ while (false !== ($entry = readdir($handle))) {
+ if ($entry[0] === '.' || ! is_dir($dashboards . '/' . $entry)) {
+ continue;
+ }
+ if (strtolower($entry) === strtolower($user->getUsername())) {
+ $files[] = $dashboards . '/' . $entry . '/dashboard.ini';
+ }
+ }
+ closedir($handle);
+ }
+ return $files;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function saveIni($filePath = null, $fileMode = 0660)
+ {
+ // Preprocessing start, ensures that the non-translated names are used to save module dashboard changes
+ // TODO: This MUST NOT survive the new dashboard implementation (yes, it's still a thing..)
+ $dashboardNavigation = new Navigation();
+ $dashboardNavigation->load('dashboard-pane');
+ $getDashboardPane = function ($label) use ($dashboardNavigation) {
+ foreach ($dashboardNavigation as $dashboardPane) {
+ /** @var DashboardPane $dashboardPane */
+ if ($dashboardPane->getLabel() === $label) {
+ return $dashboardPane;
+ }
+
+ foreach ($dashboardPane->getChildren() as $dashlet) {
+ /** @var NavigationItem $dashlet */
+ if ($dashlet->getLabel() === $label) {
+ return $dashlet;
+ }
+ }
+ }
+ };
+
+ foreach (clone $this->config as $name => $options) {
+ if (strpos($name, '.') !== false) {
+ list($dashboardLabel, $dashletLabel) = explode('.', $name, 2);
+ } else {
+ $dashboardLabel = $name;
+ $dashletLabel = null;
+ }
+
+ $dashboardPane = $getDashboardPane($dashboardLabel);
+ if ($dashboardPane !== null) {
+ $dashboardLabel = $dashboardPane->getName();
+ }
+
+ if ($dashletLabel !== null) {
+ $dashletItem = $getDashboardPane($dashletLabel);
+ if ($dashletItem !== null) {
+ $dashletLabel = $dashletItem->getName();
+ }
+ }
+
+ unset($this->config[$name]);
+ $this->config[$dashboardLabel . ($dashletLabel ? '.' . $dashletLabel : '')] = $options;
+ }
+ // Preprocessing end
+
+ parent::saveIni($filePath, $fileMode);
+ if ($filePath === null) {
+ $filePath = $this->configFile;
+ }
+ foreach (static::listConfigFilesForUser($this->user) as $file) {
+ if ($file !== $filePath) {
+ @unlink($file);
+ }
+ }
+ }
+}
diff --git a/library/Icinga/Less/Call.php b/library/Icinga/Less/Call.php
new file mode 100644
index 0000000..0a78cb5
--- /dev/null
+++ b/library/Icinga/Less/Call.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_Tree_Call;
+use Less_Tree_Color;
+use Less_Tree_Value;
+use Less_Tree_Variable;
+
+class Call extends Less_Tree_Call
+{
+ public static function fromCall(Less_Tree_Call $call)
+ {
+ return new static($call->name, $call->args, $call->index, $call->currentFileInfo);
+ }
+
+ public function compile($env = null)
+ {
+ if (! $env) {
+ // Not sure how to trigger this, but if there is no $env, there is nothing we can do
+ return parent::compile($env);
+ }
+
+ foreach ($this->args as $arg) {
+ if (! is_array($arg->value)) {
+ continue;
+ }
+
+ $name = null;
+ if ($arg->value[0] instanceof Less_Tree_Variable) {
+ // This is the case when defining a variable with a callable LESS rules such as fade, fadeout..
+ // Example: `@foo: #fff; @foo-bar: fade(@foo, 10);`
+ $name = $arg->value[0]->name;
+ } elseif ($arg->value[0] instanceof ColorPropOrVariable) {
+ // This is the case when defining a CSS rule using the LESS functions and passing
+ // a variable as an argument to them. Example: `... { color: fade(@foo, 10%); }`
+ $name = $arg->value[0]->getVariable()->name;
+ }
+
+ if ($name) {
+ foreach ($env->frames as $frame) {
+ if (($v = $frame->variable($name))) {
+ // Variables from the frame stack are always of type LESS Tree Rule
+ $vr = $v->value;
+ if ($vr instanceof Less_Tree_Value) {
+ // Get the actual color prop, otherwise this may cause an invalid argument error
+ $vr = $vr->compile($env);
+ }
+
+ if ($vr instanceof DeferredColorProp) {
+ if (! $vr->hasReference()) {
+ // Should never happen, though just for safety's sake
+ $vr->compile($env);
+ }
+
+ // Get the uppermost variable of the variable references
+ while (! $vr instanceof ColorProp) {
+ $vr = $vr->getRef();
+ }
+ } elseif ($vr instanceof Less_Tree_Color) {
+ $vr = ColorProp::fromColor($vr);
+ $vr->setName($name);
+ }
+
+ $arg->value[0] = $vr;
+
+ break;
+ }
+ }
+ }
+ }
+
+ return parent::compile($env);
+ }
+}
diff --git a/library/Icinga/Less/ColorProp.php b/library/Icinga/Less/ColorProp.php
new file mode 100644
index 0000000..3f83c5e
--- /dev/null
+++ b/library/Icinga/Less/ColorProp.php
@@ -0,0 +1,109 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_Tree_Call;
+use Less_Tree_Color;
+use Less_Tree_Keyword;
+
+/**
+ * ColorProp renders Less colors as CSS var() function calls
+ *
+ * It extends {@link Less_Tree_Color} so that Less functions that take a Less_Tree_Color as an argument do not fail.
+ */
+class ColorProp extends Less_Tree_Color
+{
+ /** @var Less_Tree_Color Color with which we created the ColorProp */
+ protected $color;
+
+ /** @var int */
+ protected $index;
+
+ /** @var string Color variable name */
+ protected $name;
+
+ public function __construct()
+ {
+ }
+
+ /**
+ * @param Less_Tree_Color $color
+ *
+ * @return static
+ */
+ public static function fromColor(Less_Tree_Color $color)
+ {
+ $self = new static();
+ $self->color = $color;
+
+ foreach ($color as $k => $v) {
+ if ($k === 'name') {
+ $self->setName($v); // Removes the @ char from the name
+ } else {
+ $self->$k = $v;
+ }
+ }
+
+ return $self;
+ }
+
+ /**
+ * @return int
+ */
+ public function getIndex()
+ {
+ return $this->index;
+ }
+
+ /**
+ * @param int $index
+ *
+ * @return $this
+ */
+ public function setIndex($index)
+ {
+ $this->index = $index;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ if ($name[0] === '@') {
+ $name = substr($name, 1);
+ }
+
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function genCSS($output)
+ {
+ $css = (new Less_Tree_Call(
+ 'var',
+ [
+ new Less_Tree_Keyword('--' . $this->getName()),
+ // Use the Less_Tree_Color with which we created the ColorProp so that we don't get into genCSS() loops.
+ $this->color
+ ],
+ $this->getIndex()
+ ))->toCSS();
+
+ $output->add($css);
+ }
+}
diff --git a/library/Icinga/Less/ColorPropOrVariable.php b/library/Icinga/Less/ColorPropOrVariable.php
new file mode 100644
index 0000000..7918674
--- /dev/null
+++ b/library/Icinga/Less/ColorPropOrVariable.php
@@ -0,0 +1,71 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_Tree;
+use Less_Tree_Color;
+use Less_Tree_Variable;
+
+/**
+ * Compile a Less variable to {@link ColorProp} if it is a color
+ */
+class ColorPropOrVariable extends Less_Tree
+{
+ public $type = 'Variable';
+
+ /** @var Less_Tree_Variable */
+ protected $variable;
+
+ /**
+ * @return Less_Tree_Variable
+ */
+ public function getVariable()
+ {
+ return $this->variable;
+ }
+
+ /**
+ * @param Less_Tree_Variable $variable
+ *
+ * @return $this
+ */
+ public function setVariable(Less_Tree_Variable $variable)
+ {
+ $this->variable = $variable;
+
+ return $this;
+ }
+
+ public function compile($env)
+ {
+ $v = $this->getVariable();
+
+ if ($v->name[1] === '@') {
+ // Evaluate variable variable as in Less_Tree_Variable:28.
+ $vv = new Less_Tree_Variable(substr($v->name, 1), $v->index + 1, $v->currentFileInfo);
+ // Overwrite the name so that the variable variable is not evaluated again.
+ $result = $vv->compile($env);
+ if ($result instanceof DeferredColorProp) {
+ $v->name = $result->name;
+ } else {
+ $v->name = '@' . $result->value;
+ }
+ }
+
+ $compiled = $v->compile($env);
+
+ if ($compiled instanceof ColorProp) {
+ // We may already have a ColorProp, which is the case with mixin calls.
+ return $compiled;
+ }
+
+ if ($compiled instanceof Less_Tree_Color) {
+ return ColorProp::fromColor($compiled)
+ ->setIndex($v->index)
+ ->setName($v->name);
+ }
+
+ return $compiled;
+ }
+}
diff --git a/library/Icinga/Less/DeferredColorProp.php b/library/Icinga/Less/DeferredColorProp.php
new file mode 100644
index 0000000..c9c39ad
--- /dev/null
+++ b/library/Icinga/Less/DeferredColorProp.php
@@ -0,0 +1,136 @@
+<?php
+
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_Exception_Compiler;
+use Less_Tree_Call;
+use Less_Tree_Color;
+use Less_Tree_Keyword;
+use Less_Tree_Value;
+use Less_Tree_Variable;
+
+class DeferredColorProp extends Less_Tree_Variable
+{
+ /** @var DeferredColorProp|ColorProp */
+ protected $reference;
+
+ protected $resolved = false;
+
+ public function __construct($name, $variable, $index = null, $currentFileInfo = null)
+ {
+ parent::__construct($name, $index, $currentFileInfo);
+
+ if ($variable instanceof Less_Tree_Variable) {
+ $this->reference = self::fromVariable($variable);
+ }
+ }
+
+ public function isResolved()
+ {
+ return $this->resolved;
+ }
+
+ public function getName()
+ {
+ $name = $this->name;
+ if ($this->name[0] === '@') {
+ $name = substr($this->name, 1);
+ }
+
+ return $name;
+ }
+
+ public function hasReference()
+ {
+ return $this->reference !== null;
+ }
+
+ public function getRef()
+ {
+ return $this->reference;
+ }
+
+ public function setReference($ref)
+ {
+ $this->reference = $ref;
+
+ return $this;
+ }
+
+ public static function fromVariable(Less_Tree_Variable $variable)
+ {
+ $static = new static($variable->name, $variable->index, $variable->currentFileInfo);
+ $static->evaluating = $variable->evaluating;
+ $static->type = $variable->type;
+
+ return $static;
+ }
+
+ public function compile($env)
+ {
+ if (! $this->hasReference()) {
+ // This is never supposed to happen, however, we might have a deferred color prop
+ // without a reference. In this case we can simply use the parent method.
+ return parent::compile($env);
+ }
+
+ if ($this->isResolved()) {
+ // The dependencies are already resolved, no need to traverse the frame stack over again!
+ return $this;
+ }
+
+ if ($this->evaluating) { // Just like the parent method
+ throw new Less_Exception_Compiler(
+ "Recursive variable definition for " . $this->name,
+ null,
+ $this->index,
+ $this->currentFileInfo
+ );
+ }
+
+ $this->evaluating = true;
+
+ foreach ($env->frames as $frame) {
+ if (($v = $frame->variable($this->getRef()->name))) {
+ $rv = $v->value;
+ if ($rv instanceof Less_Tree_Value) {
+ $rv = $rv->compile($env);
+ }
+
+ // As we are at it anyway, let's cast the tree color to our color prop as well!
+ if ($rv instanceof Less_Tree_Color) {
+ $rv = ColorProp::fromColor($rv);
+ $rv->setName($this->getRef()->getName());
+ }
+
+ $this->evaluating = false;
+ $this->resolved = true;
+ $this->setReference($rv);
+
+ break;
+ }
+ }
+
+ return $this;
+ }
+
+ public function genCSS($output)
+ {
+ if (! $this->hasReference()) {
+ return; // Nothing to generate
+ }
+
+ $css = (new Less_Tree_Call(
+ 'var',
+ [
+ new Less_Tree_Keyword('--' . $this->getName()),
+ $this->getRef() // Each of the references will be generated recursively
+ ],
+ $this->index
+ ))->toCSS();
+
+ $output->add($css);
+ }
+}
diff --git a/library/Icinga/Less/LightMode.php b/library/Icinga/Less/LightMode.php
new file mode 100644
index 0000000..b4b72a0
--- /dev/null
+++ b/library/Icinga/Less/LightMode.php
@@ -0,0 +1,128 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use ArrayIterator;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Less_Environment;
+use Traversable;
+
+/**
+ * Registry for light modes and the environments in which they are defined
+ */
+class LightMode implements IteratorAggregate
+{
+ /** @var array Mode environments as mode-environment pairs */
+ protected $envs = [];
+
+ /** @var array Assoc list of modes */
+ protected $modes = [];
+
+ /** @var array Mode selectors as mode-selector pairs */
+ protected $selectors = [];
+
+ /**
+ * @param string $mode
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the mode already exists
+ */
+ public function add($mode)
+ {
+ if (array_key_exists($mode, $this->modes)) {
+ throw new InvalidArgumentException("$mode already exists");
+ }
+
+ $this->modes[$mode] = true;
+
+ return $this;
+ }
+
+ /**
+ * @param string $mode
+ *
+ * @return Less_Environment
+ *
+ * @throws InvalidArgumentException If there is no environment for the given mode
+ */
+ public function getEnv($mode)
+ {
+ if (! isset($this->envs[$mode])) {
+ throw new InvalidArgumentException("$mode does not exist");
+ }
+
+ return $this->envs[$mode];
+ }
+
+ /**
+ * @param string $mode
+ * @param Less_Environment $env
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If an environment for given the mode already exists
+ */
+ public function setEnv($mode, Less_Environment $env)
+ {
+ if (array_key_exists($mode, $this->envs)) {
+ throw new InvalidArgumentException("$mode already exists");
+ }
+
+ $this->envs[$mode] = $env;
+
+ return $this;
+ }
+
+ /**
+ * @param string $mode
+ *
+ * @return bool
+ */
+ public function hasSelector($mode)
+ {
+ return isset($this->selectors[$mode]);
+ }
+
+ /**
+ * @param string $mode
+ *
+ * @return string
+ *
+ * @throws InvalidArgumentException If there is no selector for the given mode
+ */
+ public function getSelector($mode)
+ {
+ if (! isset($this->selectors[$mode])) {
+ throw new InvalidArgumentException("$mode does not exist");
+ }
+
+ return $this->selectors[$mode];
+ }
+
+ /**
+ * @param string $mode
+ * @param string $selector
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If a selector for given the mode already exists
+ */
+ public function setSelector($mode, $selector)
+ {
+ if (array_key_exists($mode, $this->selectors)) {
+ throw new InvalidArgumentException("$mode already exists");
+ }
+
+ $this->selectors[$mode] = $selector;
+
+ return $this;
+ }
+
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator(array_keys($this->modes));
+ }
+}
diff --git a/library/Icinga/Less/LightModeCall.php b/library/Icinga/Less/LightModeCall.php
new file mode 100644
index 0000000..d899e3c
--- /dev/null
+++ b/library/Icinga/Less/LightModeCall.php
@@ -0,0 +1,38 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_Environment;
+use Less_Tree_Ruleset;
+use Less_Tree_RulesetCall;
+
+/**
+ * Use the environment where the light mode was defined to evaluate the call
+ */
+class LightModeCall extends Less_Tree_RulesetCall
+{
+ use LightModeTrait;
+
+ /**
+ * @param Less_Tree_RulesetCall $c
+ *
+ * @return static
+ */
+ public static function fromRulesetCall(Less_Tree_RulesetCall $c)
+ {
+ return new static($c->variable);
+ }
+
+ /**
+ * @param Less_Environment $env
+ *
+ * @return Less_Tree_Ruleset
+ */
+ public function compile($env)
+ {
+ return parent::compile(
+ $env->copyEvalEnv(array_merge($env->frames, $this->getLightMode()->getEnv($this->variable)->frames))
+ );
+ }
+}
diff --git a/library/Icinga/Less/LightModeDefinition.php b/library/Icinga/Less/LightModeDefinition.php
new file mode 100644
index 0000000..929e95c
--- /dev/null
+++ b/library/Icinga/Less/LightModeDefinition.php
@@ -0,0 +1,75 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_Environment;
+use Less_Exception_Compiler;
+use Less_Tree_DetachedRuleset;
+use Less_Tree_Ruleset;
+
+/**
+ * Register the environment in which the light mode is defined
+ */
+class LightModeDefinition extends Less_Tree_DetachedRuleset
+{
+ use LightModeTrait;
+
+ /** @var string */
+ protected $name;
+
+ /**
+ * @param Less_Tree_DetachedRuleset $drs
+ *
+ * @return static
+ */
+ public static function fromDetachedRuleset(Less_Tree_DetachedRuleset $drs)
+ {
+ return new static($drs->ruleset, $drs->frames);
+ }
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * @param Less_Environment $env
+ *
+ * @return Less_Tree_DetachedRuleset
+ */
+ public function compile($env)
+ {
+ $drs = parent::compile($env);
+
+ /** @var $frame Less_Tree_Ruleset */
+ foreach ($env->frames as $frame) {
+ if ($frame->variable($this->getName())) {
+ if (! empty($frame->first_oelements) && ! isset($frame->first_oelements['.icinga-module'])) {
+ throw new Less_Exception_Compiler('Light mode definition not allowed in selectors');
+ }
+
+ break;
+ }
+ }
+
+ $this->getLightMode()->setEnv($this->getName(), $env->copyEvalEnv($env->frames));
+
+ return $drs;
+ }
+}
diff --git a/library/Icinga/Less/LightModeTrait.php b/library/Icinga/Less/LightModeTrait.php
new file mode 100644
index 0000000..d328265
--- /dev/null
+++ b/library/Icinga/Less/LightModeTrait.php
@@ -0,0 +1,30 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+trait LightModeTrait
+{
+ /** @var LightMode */
+ private $lightMode;
+
+ /**
+ * @return LightMode
+ */
+ public function getLightMode()
+ {
+ return $this->lightMode;
+ }
+
+ /**
+ * @param LightMode $lightMode
+ *
+ * @return $this
+ */
+ public function setLightMode(LightMode $lightMode)
+ {
+ $this->lightMode = $lightMode;
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Less/LightModeVisitor.php b/library/Icinga/Less/LightModeVisitor.php
new file mode 100644
index 0000000..35758b4
--- /dev/null
+++ b/library/Icinga/Less/LightModeVisitor.php
@@ -0,0 +1,26 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_VisitorReplacing;
+
+/**
+ * Ensure that light mode calls have access to the environment in which the mode was defined
+ */
+class LightModeVisitor extends Less_VisitorReplacing
+{
+ use LightModeTrait;
+
+ public $isPreVisitor = true;
+
+ public function visitRulesetCall($c)
+ {
+ return LightModeCall::fromRulesetCall($c)->setLightMode($this->getLightMode());
+ }
+
+ public function run($node)
+ {
+ return $this->visitObj($node);
+ }
+}
diff --git a/library/Icinga/Less/Visitor.php b/library/Icinga/Less/Visitor.php
new file mode 100644
index 0000000..c04a0eb
--- /dev/null
+++ b/library/Icinga/Less/Visitor.php
@@ -0,0 +1,233 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Less;
+
+use Less_Parser;
+use Less_Tree_Expression;
+use Less_Tree_Rule;
+use Less_Tree_Value;
+use Less_Tree_Variable;
+use Less_VisitorReplacing;
+use LogicException;
+use ReflectionProperty;
+
+/**
+ * Replace compiled Less colors with CSS var() function calls and inject light mode calls
+ *
+ * Color replacing basically works by replacing every visited Less variable with {@link ColorPropOrVariable},
+ * which is later compiled to {@link ColorProp} if it is a color.
+ *
+ * Light mode calls are generated from light mode definitions.
+ */
+class Visitor extends Less_VisitorReplacing
+{
+ const LIGHT_MODE_CSS = <<<'CSS'
+@media (min-height: @prefer-light-color-scheme), print,
+(prefers-color-scheme: light) and (min-height: @enable-color-preference) {
+ %s
+}
+CSS;
+
+ const LIGHT_MODE_NAME = 'light-mode';
+
+ public $isPreEvalVisitor = true;
+
+ /**
+ * Whether calling var() CSS function
+ *
+ * If that's the case, don't try to replace compiled Less colors with CSS var() function calls.
+ *
+ * @var bool|string
+ */
+ protected $callingVar = false;
+
+ /**
+ * Whether defining a variable
+ *
+ * If that's the case, don't try to replace compiled Less colors with CSS var() function calls.
+ *
+ * @var false|string
+ */
+ protected $definingVariable = false;
+
+ /** @var Less_Tree_Rule If defining a variable, determines the origin rule of the variable */
+ protected $variableOrigin;
+
+ /** @var LightMode Light mode registry */
+ protected $lightMode;
+
+ /** @var false|string Whether parsing module Less */
+ protected $moduleScope = false;
+
+ /** @var null|string CSS module selector if any */
+ protected $moduleSelector;
+
+ public function visitCall($c)
+ {
+ if ($c->name !== 'var') {
+ // We need to use our own tree call class , so that we can precompile the arguments before making
+ // the actual LESS function calls. Otherwise, it will produce lots of invalid argument exceptions!
+ $c = Call::fromCall($c);
+ }
+
+ return $c;
+ }
+
+ public function visitDetachedRuleset($drs)
+ {
+ if ($this->variableOrigin->name === '@' . static::LIGHT_MODE_NAME) {
+ $this->variableOrigin->name .= '-' . substr(sha1(uniqid(mt_rand(), true)), 0, 7);
+
+ $this->lightMode->add($this->variableOrigin->name);
+
+ if ($this->moduleSelector !== false) {
+ $this->lightMode->setSelector($this->variableOrigin->name, $this->moduleSelector);
+ }
+
+ $drs = LightModeDefinition::fromDetachedRuleset($drs)
+ ->setLightMode($this->lightMode)
+ ->setName($this->variableOrigin->name);
+ }
+
+ // Since a detached ruleset is a variable definition in the first place,
+ // just reset that we define a variable.
+ $this->definingVariable = false;
+
+ return $drs;
+ }
+
+ public function visitMixinCall($c)
+ {
+ // Less_Tree_Mixin_Call::accept() does not visit arguments, but we have to replace them if necessary.
+ foreach ($c->arguments as $a) {
+ $a['value'] = $this->visitObj($a['value']);
+ }
+
+ return $c;
+ }
+
+ public function visitMixinDefinition($m)
+ {
+ // Less_Tree_Mixin_Definition::accept() does not visit params, but we have to replace them if necessary.
+ foreach ($m->params as $p) {
+ if (! isset($p['value'])) {
+ continue;
+ }
+
+ $p['value'] = $this->visitObj($p['value']);
+ }
+
+ return $m;
+ }
+
+ public function visitRule($r)
+ {
+ if ($r->name[0] === '@' && $r->variable) {
+ if ($this->definingVariable !== false) {
+ throw new LogicException('Already defining a variable');
+ }
+
+ $this->definingVariable = spl_object_hash($r);
+ $this->variableOrigin = $r;
+
+ if ($r->value instanceof Less_Tree_Value) {
+ if ($r->value->value[0] instanceof Less_Tree_Expression) {
+ if ($r->value->value[0]->value[0] instanceof Less_Tree_Variable) {
+ // Transform the variable definition rule into our own class
+ $r->value->value[0]->value[0] = new DeferredColorProp($r->name, $r->value->value[0]->value[0]);
+ }
+ }
+ }
+ }
+
+ return $r;
+ }
+
+ public function visitRuleOut($r)
+ {
+ if ($this->definingVariable !== false && $this->definingVariable === spl_object_hash($r)) {
+ $this->definingVariable = false;
+ $this->variableOrigin = null;
+ }
+ }
+
+ public function visitRuleset($rs)
+ {
+ // Method is required, otherwise visitRulesetOut will not be called.
+ return $rs;
+ }
+
+ public function visitRulesetOut($rs)
+ {
+ if ($this->moduleScope !== false
+ && isset($rs->selectors)
+ && spl_object_hash($rs->selectors[0]) === $this->moduleScope
+ ) {
+ $this->moduleSelector = null;
+ $this->moduleScope = false;
+ }
+ }
+
+ public function visitSelector($s)
+ {
+ if ($s->_oelements_len === 2 && $s->_oelements[0] === '.icinga-module') {
+ $this->moduleSelector = implode('', $s->_oelements);
+ $this->moduleScope = spl_object_hash($s);
+ }
+
+ return $s;
+ }
+
+ public function visitVariable($v)
+ {
+ if ($this->definingVariable !== false) {
+ return $v;
+ }
+
+ return (new ColorPropOrVariable())
+ ->setVariable($v);
+ }
+
+ public function run($node)
+ {
+ $this->lightMode = new LightMode();
+
+ $evald = $this->visitObj($node);
+
+ // The visitor has registered all light modes in visitDetachedRuleset, but has not called them yet.
+ // Now the light mode calls are prepared with the appropriate CSS selectors.
+ $calls = [];
+ foreach ($this->lightMode as $mode) {
+ if ($this->lightMode->hasSelector($mode)) {
+ $calls[] = "{$this->lightMode->getSelector($mode)} {\n$mode();\n}";
+ } else {
+ $calls[] = "$mode();";
+ }
+ }
+
+ if (! empty($calls)) {
+ // Place and parse light mode calls into a new anonymous file,
+ // leaving the original Less in which the light modes were defined untouched.
+ $parser = (new Less_Parser())
+ ->parse(sprintf(static::LIGHT_MODE_CSS, implode("\n", $calls)));
+
+ // Because Less variables are block scoped,
+ // we can't just access the light mode definitions in the calls above.
+ // The LightModeVisitor ensures that all calls have access to the environment in which the mode was defined.
+ // Finally, the rules are merged so that the light mode calls are also rendered to CSS.
+ $rules = new ReflectionProperty(get_class($parser), 'rules');
+ $rules->setAccessible(true);
+ $evald->rules = array_merge(
+ $evald->rules,
+ (new LightModeVisitor())
+ ->setLightMode($this->lightMode)
+ ->visitArray($rules->getValue($parser))
+ );
+ // The LightModeVisitor is used explicitly here instead of using it as a plugin
+ // since we only need to process the newly created rules for the light mode calls.
+ }
+
+ return $evald;
+ }
+}
diff --git a/library/Icinga/Model/Schema.php b/library/Icinga/Model/Schema.php
new file mode 100644
index 0000000..465cce0
--- /dev/null
+++ b/library/Icinga/Model/Schema.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\BoolCast;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+
+/**
+ * A database model for Icinga Web schema version table
+ *
+ * @property int $id Unique identifier of the database schema entries
+ * @property string $version The current schema version of Icinga Web
+ * @property DateTime $timestamp The insert/modify time of the schema entry
+ * @property bool $success Whether the database migration of the current version was successful
+ * @property ?string $reason The reason why the database migration has failed
+ */
+class Schema extends Model
+{
+ public function getTableName(): string
+ {
+ return 'icingaweb_schema';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'version',
+ 'timestamp',
+ 'success',
+ 'reason'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new BoolCast(['success']));
+ $behaviors->add(new MillisecondTimestamp(['timestamp']));
+ }
+}
diff --git a/library/Icinga/Protocol/Dns.php b/library/Icinga/Protocol/Dns.php
new file mode 100644
index 0000000..3d422d7
--- /dev/null
+++ b/library/Icinga/Protocol/Dns.php
@@ -0,0 +1,89 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol;
+
+/**
+ * Discover dns records using regular or reverse lookup
+ */
+class Dns
+{
+ /**
+ * Discover all service records on a given domain
+ *
+ * @param string $domain The domain to search
+ * @param string $service The type of the service, like for example 'ldaps' or 'ldap'
+ * @param string $protocol The transport protocol used by the service, defaults to 'tcp'
+ *
+ * @return array An array of all found service records
+ */
+ public static function getSrvRecords($domain, $service, $protocol = 'tcp')
+ {
+ $records = dns_get_record('_' . $service . '._' . $protocol . '.' . $domain, DNS_SRV);
+ return $records === false ? array() : $records;
+ }
+
+ /**
+ * Get all ldap records for the given domain
+ *
+ * @param string $query The domain to query
+ * @param int $type The type of DNS-entry to fetch, see
+ * http://www.php.net/manual/de/function.dns-get-record.php for available types
+ *
+ * @return array|null An array of record entries
+ */
+ public static function records($query, $type = DNS_ANY)
+ {
+ return dns_get_record($query, $type);
+ }
+
+ /**
+ * Reverse lookup all host names available on the given ip address
+ *
+ * @param string $ipAddress
+ * @param int $type
+ *
+ * @return array|null
+ */
+ public static function ptr($ipAddress, $type = DNS_ANY)
+ {
+ $host = gethostbyaddr($ipAddress);
+ if ($host === false || $host === $ipAddress) {
+ // malformed input or no host found
+ return null;
+ }
+ return self::records($host, $type);
+ }
+
+ /**
+ * Get the IPv4 address of the given hostname.
+ *
+ * @param $hostname The hostname to resolve
+ *
+ * @return string|null The IPv4 address of the given hostname or null, when no entry exists.
+ */
+ public static function ipv4($hostname)
+ {
+ $records = dns_get_record($hostname, DNS_A);
+ if ($records !== false && count($records) > 0) {
+ return $records[0]['ip'];
+ }
+ return null;
+ }
+
+ /**
+ * Get the IPv6 address of the given hostname.
+ *
+ * @param $hostname The hostname to resolve
+ *
+ * @return string|null The IPv6 address of the given hostname or null, when no entry exists.
+ */
+ public static function ipv6($hostname)
+ {
+ $records = dns_get_record($hostname, DNS_AAAA);
+ if ($records !== false && count($records) > 0) {
+ return $records[0]['ip'];
+ }
+ return null;
+ }
+}
diff --git a/library/Icinga/Protocol/File/Exception/FileReaderException.php b/library/Icinga/Protocol/File/Exception/FileReaderException.php
new file mode 100644
index 0000000..237352c
--- /dev/null
+++ b/library/Icinga/Protocol/File/Exception/FileReaderException.php
@@ -0,0 +1,12 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+namespace Icinga\Protocol\File;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Exception thrown if a file reader specific error occurs
+ */
+class FileReaderException extends IcingaException
+{
+}
diff --git a/library/Icinga/Protocol/File/FileIterator.php b/library/Icinga/Protocol/File/FileIterator.php
new file mode 100644
index 0000000..64b6600
--- /dev/null
+++ b/library/Icinga/Protocol/File/FileIterator.php
@@ -0,0 +1,81 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\File;
+
+use Icinga\Util\EnumeratingFilterIterator;
+use Icinga\Util\File;
+
+/**
+ * Class FileIterator
+ *
+ * Iterate over a file, yielding only fields of non-empty lines which match a PCRE expression
+ */
+class FileIterator extends EnumeratingFilterIterator
+{
+ /**
+ * A PCRE string with the fields to extract from the file's lines as named subpatterns
+ *
+ * @var string
+ */
+ protected $fields;
+
+ /**
+ * An associative array of the current line's fields ($field => $value)
+ *
+ * @var array
+ */
+ protected $currentData;
+
+ public function __construct($filename, $fields)
+ {
+ $this->fields = $fields;
+ $f = new File($filename);
+ $f->setFlags(
+ File::DROP_NEW_LINE |
+ File::READ_AHEAD |
+ File::SKIP_EMPTY
+ );
+ parent::__construct($f);
+ }
+
+ /**
+ * Return the current data
+ *
+ * @return array
+ */
+ public function current(): array
+ {
+ return $this->currentData;
+ }
+
+ /**
+ * Accept lines matching the given PCRE pattern
+ *
+ * @return bool
+ *
+ * @throws FileReaderException If PHP failed parsing the PCRE pattern
+ */
+ public function accept(): bool
+ {
+ $data = array();
+ $matched = preg_match(
+ $this->fields,
+ $this->getInnerIterator()->current(),
+ $data
+ );
+
+ if ($matched === false) {
+ throw new FileReaderException('Failed parsing regular expression!');
+ } elseif ($matched === 1) {
+ foreach ($data as $key => $value) {
+ if (is_int($key)) {
+ unset($data[$key]);
+ }
+ }
+ $this->currentData = $data;
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/library/Icinga/Protocol/File/FileQuery.php b/library/Icinga/Protocol/File/FileQuery.php
new file mode 100644
index 0000000..504de2e
--- /dev/null
+++ b/library/Icinga/Protocol/File/FileQuery.php
@@ -0,0 +1,86 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\File;
+
+use Icinga\Data\SimpleQuery;
+use Icinga\Data\Filter\Filter;
+
+/**
+ * Class FileQuery
+ *
+ * Query for Datasource Icinga\Protocol\File\FileReader
+ *
+ * @package Icinga\Protocol\File
+ */
+class FileQuery extends SimpleQuery
+{
+ /**
+ * Sort direction
+ *
+ * @var int
+ */
+ private $sortDir;
+
+ /**
+ * Filters to apply on result
+ *
+ * @var array
+ */
+ private $filters = array();
+
+ /**
+ * Nothing to do here
+ */
+ public function applyFilter(Filter $filter)
+ {
+ }
+
+ /**
+ * Sort query result chronological
+ *
+ * @param string $dir Sort direction, 'ASC' or 'DESC' (default)
+ *
+ * @return FileQuery
+ */
+ public function order($field, $direction = null)
+ {
+ $this->sortDir = (
+ $direction === null || strtoupper(trim($direction)) === 'DESC'
+ ) ? self::SORT_DESC : self::SORT_ASC;
+ return $this;
+ }
+
+ /**
+ * Return true if sorting descending, false otherwise
+ *
+ * @return bool
+ */
+ public function sortDesc()
+ {
+ return $this->sortDir === self::SORT_DESC;
+ }
+
+ /**
+ * Add an mandatory filter expression to be applied on this query
+ *
+ * @param string $expression the filter expression to be applied
+ *
+ * @return FileQuery
+ */
+ public function andWhere($expression)
+ {
+ $this->filters[] = $expression;
+ return $this;
+ }
+
+ /**
+ * Get filters currently applied on this query
+ *
+ * @return array
+ */
+ public function getFilters()
+ {
+ return $this->filters;
+ }
+}
diff --git a/library/Icinga/Protocol/File/FileReader.php b/library/Icinga/Protocol/File/FileReader.php
new file mode 100644
index 0000000..a06494c
--- /dev/null
+++ b/library/Icinga/Protocol/File/FileReader.php
@@ -0,0 +1,208 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\File;
+
+use Countable;
+use ArrayIterator;
+use Icinga\Data\Selectable;
+use Icinga\Data\ConfigObject;
+
+/**
+ * Read file line by line
+ */
+class FileReader implements Selectable, Countable
+{
+ /**
+ * A PCRE string with the fields to extract from the file's lines as named subpatterns
+ *
+ * @var string
+ */
+ protected $fields;
+
+ /**
+ * Name of the target file
+ *
+ * @var string
+ */
+ protected $filename;
+
+ /**
+ * Cache for static::count()
+ *
+ * @var int
+ */
+ protected $count = null;
+
+ /**
+ * Create a new reader
+ *
+ * @param ConfigObject $config
+ *
+ * @throws FileReaderException If a required $config directive (filename or fields) is missing
+ */
+ public function __construct(ConfigObject $config)
+ {
+ foreach (array('filename', 'fields') as $key) {
+ if (isset($config->{$key})) {
+ $this->{$key} = $config->{$key};
+ } else {
+ throw new FileReaderException('The directive `%s\' is required', $key);
+ }
+ }
+ }
+
+ /**
+ * Instantiate a FileIterator object with the target file
+ *
+ * @return FileIterator
+ */
+ public function iterate()
+ {
+ return new LogFileIterator($this->filename, $this->fields);
+ }
+
+ /**
+ * Instantiate a FileQuery object
+ *
+ * @return FileQuery
+ */
+ public function select()
+ {
+ return new FileQuery($this);
+ }
+
+ /**
+ * Fetch and return all rows of the given query's result set using an iterator
+ *
+ * @param FileQuery $query
+ *
+ * @return ArrayIterator
+ */
+ public function query(FileQuery $query)
+ {
+ return new ArrayIterator($this->fetchAll($query));
+ }
+
+ /**
+ * Return the number of available valid lines.
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ if ($this->count === null) {
+ $this->count = iterator_count($this->iterate());
+ }
+ return $this->count;
+ }
+
+ /**
+ * Fetch result as an array of objects
+ *
+ * @param FileQuery $query
+ *
+ * @return array
+ */
+ public function fetchAll(FileQuery $query)
+ {
+ $all = array();
+ foreach ($this->fetchPairs($query) as $index => $value) {
+ $all[$index] = (object) $value;
+ }
+ return $all;
+ }
+
+ /**
+ * Fetch result as a key/value pair array
+ *
+ * @param FileQuery $query
+ *
+ * @return array
+ */
+ public function fetchPairs(FileQuery $query)
+ {
+ $skip = $query->getOffset();
+ $read = $query->getLimit();
+ if ($skip === null) {
+ $skip = 0;
+ }
+ $lines = array();
+ if ($query->sortDesc()) {
+ $count = $this->count();
+ if ($count <= $skip) {
+ return $lines;
+ } elseif ($count < ($skip + $read)) {
+ $read = $count - $skip;
+ $skip = 0;
+ } else {
+ $skip = $count - ($skip + $read);
+ }
+ }
+ foreach ($this->iterate() as $index => $line) {
+ if ($index >= $skip) {
+ if ($index >= $skip + $read) {
+ break;
+ }
+ $lines[] = $line;
+ }
+ }
+ if ($query->sortDesc()) {
+ $lines = array_reverse($lines);
+ }
+ return $lines;
+ }
+
+ /**
+ * Fetch first result row
+ *
+ * @param FileQuery $query
+ *
+ * @return object
+ */
+ public function fetchRow(FileQuery $query)
+ {
+ $all = $this->fetchAll($query);
+ if (isset($all[0])) {
+ return $all[0];
+ }
+ return null;
+ }
+
+ /**
+ * Fetch first result column
+ *
+ * @param FileQuery $query
+ *
+ * @return array
+ */
+ public function fetchColumn(FileQuery $query)
+ {
+ $column = array();
+ foreach ($this->fetchPairs($query) as $pair) {
+ foreach ($pair as $value) {
+ $column[] = $value;
+ break;
+ }
+ }
+ return $column;
+ }
+
+ /**
+ * Fetch first column value from first result row
+ *
+ * @param FileQuery $query
+ *
+ * @return mixed
+ */
+ public function fetchOne(FileQuery $query)
+ {
+ $pairs = $this->fetchPairs($query);
+ if (isset($pairs[0])) {
+ foreach ($pairs[0] as $value) {
+ return $value;
+ }
+ }
+ return null;
+ }
+}
diff --git a/library/Icinga/Protocol/File/LogFileIterator.php b/library/Icinga/Protocol/File/LogFileIterator.php
new file mode 100644
index 0000000..67a4d99
--- /dev/null
+++ b/library/Icinga/Protocol/File/LogFileIterator.php
@@ -0,0 +1,149 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\File;
+
+use Icinga\Exception\IcingaException;
+use SplFileObject;
+use Iterator;
+
+/**
+ * Iterate over a log file, yielding the regex fields of the log messages
+ */
+class LogFileIterator implements Iterator
+{
+ /**
+ * Log file
+ *
+ * @var SplFileObject
+ */
+ protected $file;
+
+ /**
+ * A PCRE string with the fields to extract
+ * from the log messages as named subpatterns
+ *
+ * @var string
+ */
+ protected $fields;
+
+ /**
+ * Value for static::current()
+ *
+ * @var array
+ */
+ protected $current;
+
+ /**
+ * Index for static::key()
+ *
+ * @var int
+ */
+ protected $index;
+
+ /**
+ * Value for static::valid()
+ *
+ * @var boolean
+ */
+ protected $valid;
+
+ /**
+ * @var string
+ */
+ protected $next = null;
+
+ /**
+ * @param string $filename The log file's name
+ * @param string $fields A PCRE string with the fields to extract
+ * from the log messages as named subpatterns
+ */
+ public function __construct($filename, $fields)
+ {
+ $this->file = new SplFileObject($filename);
+ $this->file->setFlags(
+ SplFileObject::DROP_NEW_LINE |
+ SplFileObject::READ_AHEAD
+ );
+ $this->fields = $fields;
+ }
+
+ public function rewind(): void
+ {
+ $this->file->rewind();
+ $this->index = 0;
+ $this->nextMessage();
+ }
+
+ public function next(): void
+ {
+ $this->file->next();
+ ++$this->index;
+ $this->nextMessage();
+ }
+
+ public function current(): array
+ {
+ return $this->current;
+ }
+
+ public function key(): int
+ {
+ return $this->index;
+ }
+
+ public function valid(): bool
+ {
+ return $this->valid;
+ }
+
+ protected function nextMessage()
+ {
+ $message = $this->next === null ? array() : array($this->next);
+ $this->valid = null;
+ while ($this->file->valid()) {
+ if (false === ($res = preg_match(
+ $this->fields,
+ $current = $this->file->current()
+ ))) {
+ throw new IcingaException('Failed at preg_match()');
+ }
+ if (empty($message)) {
+ if ($res === 1) {
+ $message[] = $current;
+ }
+ } elseif ($res === 1) {
+ $this->next = $current;
+ $this->valid = true;
+ break;
+ } else {
+ $message[] = $current;
+ }
+
+ $this->file->next();
+ }
+ if ($this->valid === null) {
+ $this->next = null;
+ $this->valid = ! empty($message);
+ }
+
+ if ($this->valid) {
+ while (! empty($message)) {
+ $matches = array();
+ if (false === ($res = preg_match(
+ $this->fields,
+ implode(PHP_EOL, $message),
+ $matches
+ ))) {
+ throw new IcingaException('Failed at preg_match()');
+ }
+ if ($res === 1) {
+ $this->current = $matches;
+ return;
+ }
+ array_pop($message);
+ }
+ $this->valid = false;
+ }
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/Discovery.php b/library/Icinga/Protocol/Ldap/Discovery.php
new file mode 100644
index 0000000..9c7990a
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/Discovery.php
@@ -0,0 +1,143 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Protocol\Dns;
+
+class Discovery
+{
+ /**
+ * @var LdapConnection
+ */
+ private $connection;
+
+ /**
+ * @param LdapConnection $conn The ldap connection to use for the discovery
+ */
+ public function __construct(LdapConnection $conn)
+ {
+ $this->connection = $conn;
+ }
+
+ /**
+ * Suggests a resource configuration of hostname, port and root_dn
+ * based on the discovery
+ *
+ * @return array The suggested configuration as an array
+ */
+ public function suggestResourceSettings()
+ {
+ return array(
+ 'hostname' => $this->connection->getHostname(),
+ 'port' => $this->connection->getPort(),
+ 'root_dn' => $this->connection->getCapabilities()->getDefaultNamingContext()
+ );
+ }
+
+ /**
+ * Suggests a backend configuration of base_dn, user_class and user_name_attribute
+ * based on the discovery
+ *
+ * @return array The suggested configuration as an array
+ */
+ public function suggestBackendSettings()
+ {
+ if ($this->isAd()) {
+ return array(
+ 'backend' => 'msldap',
+ 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(),
+ 'user_class' => 'user',
+ 'user_name_attribute' => 'sAMAccountName'
+ );
+ } else {
+ return array(
+ 'backend' => 'ldap',
+ 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(),
+ 'user_class' => 'inetOrgPerson',
+ 'user_name_attribute' => 'uid'
+ );
+ }
+ }
+
+ /**
+ * Whether the suggested ldap server is an ActiveDirectory
+ *
+ * @return boolean
+ */
+ public function isAd()
+ {
+ return $this->connection->getCapabilities()->isActiveDirectory();
+ }
+
+ /**
+ * Whether the discovery was successful
+ *
+ * @return bool False when the suggestions are guessed
+ */
+ public function isSuccess()
+ {
+ return $this->connection->discoverySuccessful();
+ }
+
+ /**
+ * Why the discovery failed
+ *
+ * @return \Exception|null
+ */
+ public function getError()
+ {
+ return $this->connection->getDiscoveryError();
+ }
+
+ /**
+ * Discover LDAP servers on the given domain
+ *
+ * @param ?string $domain The object containing the form elements
+ *
+ * @return Discovery True when the discovery was successful, false when the configuration was guessed
+ */
+ public static function discoverDomain($domain)
+ {
+ if (! isset($domain)) {
+ return false;
+ }
+
+ // Attempt 1: Connect to the domain directly
+ $disc = Discovery::discover($domain, 389);
+ if ($disc->isSuccess()) {
+ return $disc;
+ }
+
+ // Attempt 2: Discover all available ldap dns records and connect to the first one
+ $records = array_merge(Dns::getSrvRecords($domain, 'ldap'), Dns::getSrvRecords($domain, 'ldaps'));
+ if (isset($records[0])) {
+ $record = $records[0];
+ return Discovery::discover(
+ isset($record['target']) ? $record['target'] : $domain,
+ isset($record['port']) ? $record['port'] : $domain
+ );
+ }
+
+ // Return the first failed discovery, which will suggest properties based on guesses
+ return $disc;
+ }
+
+ /**
+ * Convenience method to instantiate a new Discovery
+ *
+ * @param $host The host on which to execute the discovery
+ * @param $port The port on which to execute the discovery
+ *
+ * @return Discovery The resulting Discovery
+ */
+ public static function discover($host, $port)
+ {
+ $conn = new LdapConnection(new ConfigObject(array(
+ 'hostname' => $host,
+ 'port' => $port
+ )));
+ return new Discovery($conn);
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapCapabilities.php b/library/Icinga/Protocol/Ldap/LdapCapabilities.php
new file mode 100644
index 0000000..721655a
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapCapabilities.php
@@ -0,0 +1,440 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Application\Logger;
+use stdClass;
+
+/**
+ * The properties and capabilities of an LDAP server
+ *
+ * Provides information about the available encryption mechanisms (StartTLS), the supported
+ * LDAP protocol (v2/v3), vendor-specific extensions or protocols controls and extensions.
+ */
+class LdapCapabilities
+{
+ const LDAP_SERVER_START_TLS_OID = '1.3.6.1.4.1.1466.20037';
+
+ const LDAP_PAGED_RESULT_OID_STRING = '1.2.840.113556.1.4.319';
+
+ const LDAP_SERVER_SHOW_DELETED_OID = '1.2.840.113556.1.4.417';
+
+ const LDAP_SERVER_SORT_OID = '1.2.840.113556.1.4.473';
+
+ const LDAP_SERVER_CROSSDOM_MOVE_TARGET_OID = '1.2.840.113556.1.4.521';
+
+ const LDAP_SERVER_NOTIFICATION_OID = '1.2.840.113556.1.4.528';
+
+ const LDAP_SERVER_EXTENDED_DN_OID = '1.2.840.113556.1.4.529';
+
+ const LDAP_SERVER_LAZY_COMMIT_OID = '1.2.840.113556.1.4.619';
+
+ const LDAP_SERVER_SD_FLAGS_OID = '1.2.840.113556.1.4.801';
+
+ const LDAP_SERVER_TREE_DELETE_OID = '1.2.840.113556.1.4.805';
+
+ const LDAP_SERVER_DIRSYNC_OID = '1.2.840.113556.1.4.841';
+
+ const LDAP_SERVER_VERIFY_NAME_OID = '1.2.840.113556.1.4.1338';
+
+ const LDAP_SERVER_DOMAIN_SCOPE_OID = '1.2.840.113556.1.4.1339';
+
+ const LDAP_SERVER_SEARCH_OPTIONS_OID = '1.2.840.113556.1.4.1340';
+
+ const LDAP_SERVER_PERMISSIVE_MODIFY_OID = '1.2.840.113556.1.4.1413';
+
+ const LDAP_SERVER_ASQ_OID = '1.2.840.113556.1.4.1504';
+
+ const LDAP_SERVER_FAST_BIND_OID = '1.2.840.113556.1.4.1781';
+
+ const LDAP_CONTROL_VLVREQUEST = '2.16.840.1.113730.3.4.9';
+
+
+ // MS Capabilities, Source: http://msdn.microsoft.com/en-us/library/cc223359.aspx
+
+ // Running Active Directory as AD DS
+ const LDAP_CAP_ACTIVE_DIRECTORY_OID = '1.2.840.113556.1.4.800';
+
+ // Capable of signing and sealing on an NTLM authenticated connection
+ // and of performing subsequent binds on a signed or sealed connection
+ const LDAP_CAP_ACTIVE_DIRECTORY_LDAP_INTEG_OID = '1.2.840.113556.1.4.1791';
+
+ // If AD DS: running at least W2K3, if AD LDS running at least W2K8
+ const LDAP_CAP_ACTIVE_DIRECTORY_V51_OID = '1.2.840.113556.1.4.1670';
+
+ // If AD LDS: accepts DIGEST-MD5 binds for AD LDSsecurity principals
+ const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_DIGEST = '1.2.840.113556.1.4.1880';
+
+ // Running Active Directory as AD LDS
+ const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_OID = '1.2.840.113556.1.4.1851';
+
+ // If AD DS: it's a Read Only DC (RODC)
+ const LDAP_CAP_ACTIVE_DIRECTORY_PARTIAL_SECRETS_OID = '1.2.840.113556.1.4.1920';
+
+ // Running at least W2K8
+ const LDAP_CAP_ACTIVE_DIRECTORY_V60_OID = '1.2.840.113556.1.4.1935';
+
+ // Running at least W2K8r2
+ const LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID = '1.2.840.113556.1.4.2080';
+
+ // Running at least W2K12
+ const LDAP_CAP_ACTIVE_DIRECTORY_W8_OID = '1.2.840.113556.1.4.2237';
+
+ /**
+ * Attributes of the LDAP Server returned by the discovery query
+ *
+ * @var stdClass
+ */
+ private $attributes;
+
+ /**
+ * Map of supported available OIDS
+ *
+ * @var array
+ */
+ private $oids;
+
+ /**
+ * Construct a new capability
+ *
+ * @param $attributes stdClass The attributes returned, may be null for guessing default capabilities
+ */
+ public function __construct($attributes = null)
+ {
+ $this->setAttributes($attributes);
+ }
+
+ /**
+ * Set the attributes and (re)build the OIDs
+ *
+ * @param $attributes stdClass The attributes returned, may be null for guessing default capabilities
+ */
+ protected function setAttributes($attributes)
+ {
+ $this->attributes = $attributes;
+ $this->oids = array();
+
+ $keys = array('supportedControl', 'supportedExtension', 'supportedFeatures', 'supportedCapabilities');
+ foreach ($keys as $key) {
+ if (isset($attributes->$key)) {
+ if (is_array($attributes->$key)) {
+ foreach ($attributes->$key as $oid) {
+ $this->oids[$oid] = true;
+ }
+ } else {
+ $this->oids[$attributes->$key] = true;
+ }
+ }
+ }
+ }
+
+ /**
+ * Return if the capability object contains support for StartTLS
+ *
+ * @return bool Whether StartTLS is supported
+ */
+ public function hasStartTls()
+ {
+ return isset($this->oids[self::LDAP_SERVER_START_TLS_OID]);
+ }
+
+ /**
+ * Return if the capability object contains support for paged results
+ *
+ * @return bool Whether StartTLS is supported
+ */
+ public function hasPagedResult()
+ {
+ return isset($this->oids[self::LDAP_PAGED_RESULT_OID_STRING]);
+ }
+
+ /**
+ * Whether the ldap server is an ActiveDirectory server
+ *
+ * @return boolean
+ */
+ public function isActiveDirectory()
+ {
+ return isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_OID]);
+ }
+
+ /**
+ * Whether the ldap server is an OpenLDAP server
+ *
+ * @return bool
+ */
+ public function isOpenLdap()
+ {
+ return isset($this->attributes->structuralObjectClass) &&
+ $this->attributes->structuralObjectClass === 'OpenLDAProotDSE';
+ }
+
+ /**
+ * Return if the capability objects contains support for LdapV3, defaults to true if discovery failed
+ *
+ * @return bool
+ */
+ public function hasLdapV3()
+ {
+ if (! isset($this->attributes) || ! isset($this->attributes->supportedLDAPVersion)) {
+ // Default to true, if unknown
+ return true;
+ }
+
+ return (is_string($this->attributes->supportedLDAPVersion)
+ && (int) $this->attributes->supportedLDAPVersion === 3)
+ || (is_array($this->attributes->supportedLDAPVersion)
+ && in_array(3, $this->attributes->supportedLDAPVersion));
+ }
+
+ /**
+ * Whether the capability with the given OID is supported
+ *
+ * @param $oid string The OID of the capability
+ *
+ * @return bool
+ */
+ public function hasOid($oid)
+ {
+ return isset($this->oids[$oid]);
+ }
+
+ /**
+ * Get the default naming context
+ *
+ * @return string|null the default naming context, or null when no contexts are available
+ */
+ public function getDefaultNamingContext()
+ {
+ // defaultNamingContext entry has higher priority
+ if (isset($this->attributes->defaultNamingContext)) {
+ return $this->attributes->defaultNamingContext;
+ }
+
+ // if its missing use namingContext
+ $namingContexts = $this->namingContexts();
+ return empty($namingContexts) ? null : $namingContexts[0];
+ }
+
+ /**
+ * Get the configuration naming context
+ *
+ * @return string|null
+ */
+ public function getConfigurationNamingContext()
+ {
+ if (isset($this->attributes->configurationNamingContext)) {
+ return $this->attributes->configurationNamingContext;
+ }
+ }
+
+ /**
+ * Get the NetBIOS name
+ *
+ * @return string|null
+ */
+ public function getNetBiosName()
+ {
+ if (isset($this->attributes->nETBIOSName)) {
+ return $this->attributes->nETBIOSName;
+ }
+ }
+
+ /**
+ * Fetch the namingContexts
+ *
+ * @return array the available naming contexts
+ */
+ public function namingContexts()
+ {
+ if (!isset($this->attributes->namingContexts)) {
+ return array();
+ }
+ if (!is_array($this->attributes->namingContexts)) {
+ return array($this->attributes->namingContexts);
+ }
+ return$this->attributes->namingContexts;
+ }
+
+ public function getVendor()
+ {
+ /*
+ rfc #3045 specifies that the name of the server MAY be included in the attribute 'verndorName',
+ AD and OpenLDAP don't do this, but for all all other vendors we follow the standard and
+ just hope for the best.
+ */
+
+ if ($this->isActiveDirectory()) {
+ return 'Microsoft Active Directory';
+ }
+
+ if ($this->isOpenLdap()) {
+ return 'OpenLDAP';
+ }
+
+ if (! isset($this->attributes->vendorName)) {
+ return null;
+ }
+ return $this->attributes->vendorName;
+ }
+
+ public function getVersion()
+ {
+ /*
+ rfc #3045 specifies that the version of the server MAY be included in the attribute 'vendorVersion',
+ but AD and OpenLDAP don't do this. For OpenLDAP there is no way to query the server versions, but for all
+ all other vendors we follow the standard and just hope for the best.
+ */
+
+ if ($this->isActiveDirectory()) {
+ return $this->getAdObjectVersionName();
+ }
+
+ if (! isset($this->attributes->vendorVersion)) {
+ return null;
+ }
+ return $this->attributes->vendorVersion;
+ }
+
+ /**
+ * Discover the capabilities of the given LDAP server
+ *
+ * @param LdapConnection $connection The ldap connection to use
+ *
+ * @return LdapCapabilities
+ *
+ * @throws LdapException In case the capability query has failed
+ */
+ public static function discoverCapabilities(LdapConnection $connection)
+ {
+ $ds = $connection->getConnection();
+
+ $fields = array(
+ 'configurationNamingContext',
+ 'defaultNamingContext',
+ 'namingContexts',
+ 'vendorName',
+ 'vendorVersion',
+ 'supportedSaslMechanisms',
+ 'dnsHostName',
+ 'schemaNamingContext',
+ 'supportedLDAPVersion', // => array(3, 2)
+ 'supportedCapabilities',
+ 'supportedControl',
+ 'supportedExtension',
+ 'objectVersion',
+ '+'
+ );
+
+ $result = @ldap_read($ds, '', (string) $connection->select()->from('*', $fields), $fields);
+ if (! $result) {
+ throw new LdapException(
+ 'Capability query failed (%s; Default port: %d): %s. Check if hostname and port'
+ . ' of the ldap resource are correct and if anonymous access is permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ ldap_error($ds)
+ );
+ }
+
+ $entry = ldap_first_entry($ds, $result);
+ if ($entry === false) {
+ throw new LdapException(
+ 'Capabilities not available (%s; Default port: %d): %s. Discovery of root DSE probably not permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ ldap_error($ds)
+ );
+ }
+
+ $cap = new LdapCapabilities($connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields));
+ $cap->discoverAdConfigOptions($connection);
+
+ if (isset($cap->attributes) && Logger::getInstance()->getLevel() === Logger::DEBUG) {
+ Logger::debug('Capability query discovered the following attributes:');
+ foreach ($cap->attributes as $name => $value) {
+ if ($value !== null) {
+ Logger::debug(' %s = %s', $name, $value);
+ }
+ }
+ Logger::debug('Capability query attribute listing ended.');
+ }
+
+ return $cap;
+ }
+
+ /**
+ * Discover the AD-specific configuration options of the given LDAP server
+ *
+ * @param LdapConnection $connection The ldap connection to use
+ *
+ * @throws LdapException In case the configuration options query has failed
+ */
+ protected function discoverAdConfigOptions(LdapConnection $connection)
+ {
+ if ($this->isActiveDirectory()) {
+ $configurationNamingContext = $this->getConfigurationNamingContext();
+ $defaultNamingContext = $this->getDefaultNamingContext();
+ if (!($configurationNamingContext === null || $defaultNamingContext === null)) {
+ $ds = $connection->bind()->getConnection();
+ $adFields = array('nETBIOSName');
+ $partitions = 'CN=Partitions,' . $configurationNamingContext;
+
+ $result = @ldap_list(
+ $ds,
+ $partitions,
+ (string) $connection->select()->from('*', $adFields)->where('nCName', $defaultNamingContext),
+ $adFields
+ );
+ if ($result) {
+ $entry = ldap_first_entry($ds, $result);
+ if ($entry === false) {
+ throw new LdapException(
+ 'Configuration options not available (%s:%d). Discovery of "%s" probably not permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ $partitions
+ );
+ }
+
+ $this->setAttributes((object) array_merge(
+ (array) $this->attributes,
+ (array) $connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $adFields)
+ ));
+ } else {
+ if (ldap_errno($ds) !== 1) {
+ // One stands for "operations error" which occurs if not bound non-anonymously.
+
+ throw new LdapException(
+ 'Configuration options query failed (%s:%d): %s. Check if hostname and port of the'
+ . ' ldap resource are correct and if anonymous access is permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ ldap_error($ds)
+ );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Determine the active directory version using the available capabillities
+ *
+ * @return null|string The server version description or null when unknown
+ */
+ protected function getAdObjectVersionName()
+ {
+ if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_W8_OID])) {
+ return 'Windows Server 2012 (or newer)';
+ }
+ if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID])) {
+ return 'Windows Server 2008 R2 (or newer)';
+ }
+ if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V60_OID])) {
+ return 'Windows Server 2008 (or newer)';
+ }
+ return null;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapConnection.php b/library/Icinga/Protocol/Ldap/LdapConnection.php
new file mode 100644
index 0000000..a620e6d
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapConnection.php
@@ -0,0 +1,1584 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use ArrayIterator;
+use Exception;
+use Icinga\Data\Filter\FilterNot;
+use LogicException;
+use stdClass;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Data\Selectable;
+use Icinga\Data\Sortable;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Url;
+
+/**
+ * Encapsulate LDAP connections and query creation
+ */
+class LdapConnection implements Selectable, Inspectable
+{
+ /**
+ * Indicates that the target object cannot be found
+ *
+ * @var int
+ */
+ const LDAP_NO_SUCH_OBJECT = 32;
+
+ /**
+ * Indicates that in a search operation, the size limit specified by the client or the server has been exceeded
+ *
+ * @var int
+ */
+ const LDAP_SIZELIMIT_EXCEEDED = 4;
+
+ /**
+ * Indicates that an LDAP server limit set by an administrative authority has been exceeded
+ *
+ * @var int
+ */
+ const LDAP_ADMINLIMIT_EXCEEDED = 11;
+
+ /**
+ * Indicates that during a bind operation one of the following occurred: The client passed either an incorrect DN
+ * or password, or the password is incorrect because it has expired, intruder detection has locked the account, or
+ * another similar reason.
+ *
+ * @var int
+ */
+ const LDAP_INVALID_CREDENTIALS = 49;
+
+ /**
+ * The default page size to use for paged queries
+ *
+ * @var int
+ */
+ const PAGE_SIZE = 1000;
+
+ /**
+ * Encrypt connection using STARTTLS (upgrading a plain text connection)
+ *
+ * @var string
+ */
+ const STARTTLS = 'starttls';
+
+ /**
+ * Encrypt connection using LDAP over SSL (using a separate port)
+ *
+ * @var string
+ */
+ const LDAPS = 'ldaps';
+
+ /** @var ConfigObject Connection configuration */
+ protected $config;
+
+ /**
+ * Encryption for the connection if any
+ *
+ * @var string
+ */
+ protected $encryption;
+
+ /**
+ * The LDAP link identifier being used
+ *
+ * @var resource
+ */
+ protected $ds;
+
+ /**
+ * The ip address, hostname or ldap URI being used to connect with the LDAP server
+ *
+ * @var string
+ */
+ protected $hostname;
+
+ /**
+ * The port being used to connect with the LDAP server
+ *
+ * @var int
+ */
+ protected $port;
+
+ /**
+ * The distinguished name being used to bind to the LDAP server
+ *
+ * @var string
+ */
+ protected $bindDn;
+
+ /**
+ * The password being used to bind to the LDAP server
+ *
+ * @var string
+ */
+ protected $bindPw;
+
+ /**
+ * The distinguished name being used as the base path for queries which do not provide one theirselves
+ *
+ * @var string
+ */
+ protected $rootDn;
+
+ /**
+ * Whether the bind on this connection has already been performed
+ *
+ * @var bool
+ */
+ protected $bound;
+
+ /**
+ * The current connection's root node
+ *
+ * @var Root
+ */
+ protected $root;
+
+ /**
+ * LDAP_OPT_NETWORK_TIMEOUT for the LDAP connection
+ *
+ * @var int
+ */
+ protected $timeout;
+
+ /**
+ * The properties and capabilities of the LDAP server
+ *
+ * @var LdapCapabilities
+ */
+ protected $capabilities;
+
+ /**
+ * Whether discovery was successful
+ *
+ * @var bool
+ */
+ protected $discoverySuccess;
+
+ /**
+ * The cause of the discovery's failure
+ *
+ * @var Exception|null
+ */
+ private $discoveryError;
+
+ /**
+ * Whether the current connection is encrypted
+ *
+ * @var bool
+ */
+ protected $encrypted = null;
+
+ /**
+ * Create a new connection object
+ *
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->config = $config;
+ $this->hostname = $config->hostname;
+ $this->bindDn = $config->bind_dn;
+ $this->bindPw = $config->bind_pw;
+ $this->rootDn = $config->root_dn;
+ $this->port = (int) $config->get('port', 389);
+ $this->timeout = (int) $config->get('timeout', 5);
+
+ $this->encryption = $config->encryption;
+ if ($this->encryption !== null) {
+ $this->encryption = strtolower($this->encryption);
+ }
+ }
+
+ /**
+ * Return the ip address, hostname or ldap URI being used to connect with the LDAP server
+ *
+ * @return string
+ */
+ public function getHostname()
+ {
+ return $this->hostname;
+ }
+
+ /**
+ * Return the port being used to connect with the LDAP server
+ *
+ * @return int
+ */
+ public function getPort()
+ {
+ return $this->port;
+ }
+
+ /**
+ * Return the distinguished name being used as the base path for queries which do not provide one theirselves
+ *
+ * @return string
+ */
+ public function getDn()
+ {
+ return $this->rootDn;
+ }
+
+ /**
+ * Return the root node for this connection
+ *
+ * @return Root
+ */
+ public function root()
+ {
+ if ($this->root === null) {
+ $this->root = Root::forConnection($this);
+ }
+
+ return $this->root;
+ }
+
+ /**
+ * Return the LDAP link identifier being used
+ *
+ * Establishes a connection if necessary.
+ *
+ * @return resource
+ */
+ public function getConnection()
+ {
+ if ($this->ds === null) {
+ $this->ds = $this->prepareNewConnection();
+ }
+
+ return $this->ds;
+ }
+
+ /**
+ * Return the capabilities of the current connection
+ *
+ * @return LdapCapabilities
+ */
+ public function getCapabilities()
+ {
+ if ($this->capabilities === null) {
+ try {
+ $this->capabilities = LdapCapabilities::discoverCapabilities($this);
+ $this->discoverySuccess = true;
+ $this->discoveryError = null;
+ } catch (LdapException $e) {
+ Logger::debug($e);
+ Logger::warning('LADP discovery failed, assuming default LDAP capabilities.');
+ $this->capabilities = new LdapCapabilities(); // create empty default capabilities
+ $this->discoverySuccess = false;
+ $this->discoveryError = $e;
+ }
+ }
+
+ return $this->capabilities;
+ }
+
+ /**
+ * Return whether discovery was successful
+ *
+ * @return bool true if the capabilities were successfully determined, false if the capabilities were guessed
+ */
+ public function discoverySuccessful()
+ {
+ if ($this->discoverySuccess === null) {
+ $this->getCapabilities(); // Initializes self::$discoverySuccess
+ }
+
+ return $this->discoverySuccess;
+ }
+
+ /**
+ * Get discovery error if any
+ *
+ * @return Exception|null
+ */
+ public function getDiscoveryError()
+ {
+ return $this->discoveryError;
+ }
+
+ /**
+ * Return whether the current connection is encrypted
+ *
+ * @return bool
+ */
+ public function isEncrypted()
+ {
+ if ($this->encrypted === null) {
+ return false;
+ }
+
+ return $this->encrypted;
+ }
+
+ /**
+ * Perform a LDAP bind on the current connection
+ *
+ * @throws LdapException In case the LDAP bind was unsuccessful or insecure
+ */
+ public function bind()
+ {
+ if ($this->bound) {
+ return $this;
+ }
+
+ $ds = $this->getConnection();
+
+ $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
+ if (! $success) {
+ throw new LdapException(
+ 'LDAP bind (%s / %s) to %s failed: %s',
+ $this->bindDn,
+ '***' /* $this->bindPw */,
+ $this->normalizeHostname($this->hostname),
+ ldap_error($ds)
+ );
+ }
+
+ $this->bound = true;
+ return $this;
+ }
+
+ /**
+ * Provide a query on this connection
+ *
+ * @return LdapQuery
+ */
+ public function select()
+ {
+ return new LdapQuery($this);
+ }
+
+ /**
+ * Fetch and return all rows of the given query's result set using an iterator
+ *
+ * @param LdapQuery $query The query returning the result set
+ *
+ * @return ArrayIterator
+ */
+ public function query(LdapQuery $query)
+ {
+ return new ArrayIterator($this->fetchAll($query));
+ }
+
+ /**
+ * Count all rows of the given query's result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ *
+ * @return int
+ */
+ public function count(LdapQuery $query)
+ {
+ $this->bind();
+
+ if (($unfoldAttribute = $query->getUnfoldAttribute()) !== null) {
+ $desiredColumns = $query->getColumns();
+ if (isset($desiredColumns[$unfoldAttribute])) {
+ $fields = array($unfoldAttribute => $desiredColumns[$unfoldAttribute]);
+ } elseif (in_array($unfoldAttribute, $desiredColumns, true)) {
+ $fields = array($unfoldAttribute);
+ } else {
+ throw new ProgrammingError(
+ 'The attribute used to unfold a query\'s result must be selected'
+ );
+ }
+
+ $res = $this->runQuery($query, $fields);
+ return count($res);
+ }
+
+ $ds = $this->getConnection();
+ $results = $this->ldapSearch($query, array('dn'));
+
+ if ($results === false) {
+ if (ldap_errno($ds) !== self::LDAP_NO_SUCH_OBJECT) {
+ throw new LdapException(
+ 'LDAP count query "%s" (base %s) failed: %s',
+ (string) $query,
+ $query->getBase() ?: $this->getDn(),
+ ldap_error($ds)
+ );
+ }
+ }
+
+ return ldap_count_entries($ds, $results);
+ }
+
+ /**
+ * Retrieve an array containing all rows of the result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ */
+ public function fetchAll(LdapQuery $query, array $fields = null)
+ {
+ $this->bind();
+
+ if ($query->getUsePagedResults() && $this->getCapabilities()->hasPagedResult()) {
+ return $this->runPagedQuery($query, $fields);
+ } else {
+ return $this->runQuery($query, $fields);
+ }
+ }
+
+ /**
+ * Fetch the first row of the result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return mixed
+ */
+ public function fetchRow(LdapQuery $query, array $fields = null)
+ {
+ $clonedQuery = clone $query;
+ $clonedQuery->limit(1);
+ $clonedQuery->setUsePagedResults(false);
+ $results = $this->fetchAll($clonedQuery, $fields);
+ return array_shift($results) ?: false;
+ }
+
+ /**
+ * Fetch the first column of all rows of the result set as an array
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case no attribute is being requested
+ */
+ public function fetchColumn(LdapQuery $query, array $fields = null)
+ {
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ if (empty($fields)) {
+ throw new ProgrammingError('You must request at least one attribute when fetching a single column');
+ }
+
+ $alias = key($fields);
+ $results = $this->fetchAll($query, array($alias => current($fields)));
+ $column = is_int($alias) ? current($fields) : $alias;
+ $values = array();
+ foreach ($results as $row) {
+ if (isset($row->$column)) {
+ $values[] = $row->$column;
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Fetch the first column of the first row of the result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return string
+ */
+ public function fetchOne(LdapQuery $query, array $fields = null)
+ {
+ $row = $this->fetchRow($query, $fields);
+ if ($row === false) {
+ return false;
+ }
+
+ $values = get_object_vars($row);
+ if (empty($values)) {
+ return false;
+ }
+
+ if ($fields === null) {
+ // Fetch the desired columns from the query if not explicitly overriden in the method's parameter
+ $fields = $query->getColumns();
+ }
+
+ if (empty($fields)) {
+ // The desired columns may be empty independently whether provided by the query or the method's parameter
+ return array_shift($values);
+ }
+
+ $alias = key($fields);
+ return $values[is_string($alias) ? $alias : $fields[$alias]];
+ }
+
+ /**
+ * Fetch all rows of the result set as an array of key-value pairs
+ *
+ * The first column is the key, the second column is the value.
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case there are less than two attributes being requested
+ */
+ public function fetchPairs(LdapQuery $query, array $fields = null)
+ {
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ if (count($fields) < 2) {
+ throw new ProgrammingError('You are required to request at least two attributes');
+ }
+
+ $columns = $desiredColumnNames = array();
+ foreach ($fields as $alias => $column) {
+ if (is_int($alias)) {
+ $columns[] = $column;
+ $desiredColumnNames[] = $column;
+ } else {
+ $columns[$alias] = $column;
+ $desiredColumnNames[] = $alias;
+ }
+
+ if (count($desiredColumnNames) === 2) {
+ break;
+ }
+ }
+
+ $results = $this->fetchAll($query, $columns);
+ $pairs = array();
+ foreach ($results as $row) {
+ $colOne = $desiredColumnNames[0];
+ $colTwo = $desiredColumnNames[1];
+ $pairs[$row->$colOne] = $row->$colTwo;
+ }
+
+ return $pairs;
+ }
+
+ /**
+ * Fetch an LDAP entry by its DN
+ *
+ * @param string $dn
+ * @param array|null $fields
+ *
+ * @return StdClass|bool
+ */
+ public function fetchByDn($dn, array $fields = null)
+ {
+ return $this->select()
+ ->from('*', $fields)
+ ->setBase($dn)
+ ->setScope('base')
+ ->fetchRow();
+ }
+
+ /**
+ * Test the given LDAP credentials by establishing a connection and attempting a LDAP bind
+ *
+ * @param string $bindDn
+ * @param string $bindPw
+ *
+ * @return bool Whether the given credentials are valid
+ *
+ * @throws LdapException In case an error occured while establishing the connection or attempting the bind
+ */
+ public function testCredentials($bindDn, $bindPw)
+ {
+ $ds = $this->getConnection();
+ $success = @ldap_bind($ds, $bindDn, $bindPw);
+ if (! $success) {
+ if (ldap_errno($ds) === self::LDAP_INVALID_CREDENTIALS) {
+ Logger::debug(
+ 'Testing LDAP credentials (%s / %s) failed: %s',
+ $bindDn,
+ '***',
+ ldap_error($ds)
+ );
+ return false;
+ }
+
+ throw new LdapException(ldap_error($ds));
+ }
+
+ return true;
+ }
+
+ /**
+ * Return whether an entry identified by the given distinguished name exists
+ *
+ * @param string $dn
+ *
+ * @return bool
+ */
+ public function hasDn($dn)
+ {
+ $ds = $this->getConnection();
+ $this->bind();
+
+ $result = ldap_read($ds, $dn, '(objectClass=*)', array('objectClass'));
+ return ldap_count_entries($ds, $result) > 0;
+ }
+
+ /**
+ * Delete a root entry and all of its children identified by the given distinguished name
+ *
+ * @param string $dn
+ *
+ * @return bool
+ *
+ * @throws LdapException In case an error occured while deleting an entry
+ */
+ public function deleteRecursively($dn)
+ {
+ $ds = $this->getConnection();
+ $this->bind();
+
+ $result = @ldap_list($ds, $dn, '(objectClass=*)', array('objectClass'));
+ if ($result === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ return false;
+ }
+
+ throw new LdapException('LDAP list for "%s" failed: %s', $dn, ldap_error($ds));
+ }
+
+ $children = ldap_get_entries($ds, $result);
+ for ($i = 0; $i < $children['count']; $i++) {
+ $result = $this->deleteRecursively($children[$i]['dn']);
+ if (! $result) {
+ // TODO: return result code, if delete fails
+ throw new LdapException('Recursively deleting "%s" failed', $dn);
+ }
+ }
+
+ return $this->deleteDn($dn);
+ }
+
+ /**
+ * Delete a single entry identified by the given distinguished name
+ *
+ * @param string $dn
+ *
+ * @return bool
+ *
+ * @throws LdapException In case an error occured while deleting the entry
+ */
+ public function deleteDn($dn)
+ {
+ $ds = $this->getConnection();
+ $this->bind();
+
+ $result = @ldap_delete($ds, $dn);
+ if ($result === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ return false; // TODO: Isn't it a success if something i'd like to remove is not existing at all???
+ }
+
+ throw new LdapException('LDAP delete for "%s" failed: %s', $dn, ldap_error($ds));
+ }
+
+ return true;
+ }
+
+ /**
+ * Fetch the distinguished name of the result of the given query
+ *
+ * @param LdapQuery $query The query returning the result set
+ *
+ * @return string The distinguished name, or false when the given query yields no results
+ *
+ * @throws LdapException In case the query yields multiple results
+ */
+ public function fetchDn(LdapQuery $query)
+ {
+ $rows = $this->fetchAll($query, array());
+ if (count($rows) > 1) {
+ throw new LdapException('Cannot fetch single DN for %s', $query);
+ }
+
+ return key($rows);
+ }
+
+ /**
+ * Run the given LDAP query and return the resulting entries
+ *
+ * @param LdapQuery $query The query to fetch results with
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ *
+ * @throws LdapException In case an error occured while fetching the results
+ */
+ protected function runQuery(LdapQuery $query, array $fields = null)
+ {
+ $limit = $query->getLimit();
+ $offset = $query->hasOffset() ? $query->getOffset() : 0;
+
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ $ds = $this->getConnection();
+
+ $serverSorting = ! $this->config->disable_server_side_sort
+ && $this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);
+
+ if ($query->hasOrder()) {
+ if ($serverSorting) {
+ ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array(
+ array(
+ 'oid' => LdapCapabilities::LDAP_SERVER_SORT_OID,
+ 'value' => $this->encodeSortRules($query->getOrder())
+ )
+ ));
+ } elseif (! empty($fields)) {
+ foreach ($query->getOrder() as $rule) {
+ if (! in_array($rule[0], $fields, true)) {
+ $fields[] = $rule[0];
+ }
+ }
+ }
+ }
+
+ $unfoldAttribute = $query->getUnfoldAttribute();
+ if ($unfoldAttribute) {
+ foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
+ $fieldKey = array_search($filterColumn, $fields, true);
+ if ($fieldKey === false || is_string($fieldKey)) {
+ $fields[] = $filterColumn;
+ }
+ }
+ }
+
+ $results = $this->ldapSearch(
+ $query,
+ array_values($fields),
+ 0,
+ ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0
+ );
+ if ($results === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ return array();
+ }
+
+ throw new LdapException(
+ 'LDAP query "%s" (base %s) failed. Error: %s',
+ $query,
+ $query->getBase() ?: $this->rootDn,
+ ldap_error($ds)
+ );
+ } elseif (ldap_count_entries($ds, $results) === 0) {
+ return array();
+ }
+
+ $count = 0;
+ $entries = array();
+ $entry = ldap_first_entry($ds, $results);
+ do {
+ if ($unfoldAttribute) {
+ $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
+ if (is_array($rows)) {
+ // TODO: Register the DN the same way as a section name in the ArrayDatasource!
+ foreach ($rows as $row) {
+ if ($query->getFilter()->matches($row)) {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[] = $row;
+ }
+
+ if ($serverSorting && $limit > 0 && $limit === count($entries)) {
+ break;
+ }
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $rows;
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
+ ldap_get_attributes($ds, $entry),
+ $fields
+ );
+ }
+ }
+ } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
+ && ($entry = ldap_next_entry($ds, $entry))
+ );
+
+ if (! $serverSorting) {
+ if ($query->hasOrder()) {
+ uasort($entries, array($query, 'compare'));
+ }
+
+ if ($limit && $count > $limit) {
+ $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
+ }
+ }
+
+ ldap_free_result($results);
+ return $entries;
+ }
+
+ /**
+ * Run the given LDAP query and return the resulting entries
+ *
+ * This utilizes paged search requests as defined in RFC 2696.
+ *
+ * @param LdapQuery $query The query to fetch results with
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ * @param int $pageSize The maximum page size, defaults to self::PAGE_SIZE
+ *
+ * @return array
+ *
+ * @throws LdapException In case an error occured while fetching the results
+ */
+ protected function runPagedQuery(LdapQuery $query, array $fields = null, $pageSize = null)
+ {
+ if ($pageSize === null) {
+ $pageSize = static::PAGE_SIZE;
+ }
+
+ $limit = $query->getLimit();
+ $offset = $query->hasOffset() ? $query->getOffset() : 0;
+
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ $ds = $this->getConnection();
+
+ $serverSorting = false;//$this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);
+ if (! $serverSorting && $query->hasOrder() && ! empty($fields)) {
+ foreach ($query->getOrder() as $rule) {
+ if (! in_array($rule[0], $fields, true)) {
+ $fields[] = $rule[0];
+ }
+ }
+ }
+
+ $unfoldAttribute = $query->getUnfoldAttribute();
+ if ($unfoldAttribute) {
+ foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
+ $fieldKey = array_search($filterColumn, $fields, true);
+ if ($fieldKey === false || is_string($fieldKey)) {
+ $fields[] = $filterColumn;
+ }
+ }
+ }
+
+ $controls = [];
+ $legacyControlHandling = version_compare(PHP_VERSION, '7.3.0') < 0;
+ if ($serverSorting && $query->hasOrder()) {
+ $control = [
+ 'oid' => LDAP_CONTROL_SORTREQUEST,
+ 'value' => $this->encodeSortRules($query->getOrder())
+ ];
+ if ($legacyControlHandling) {
+ ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, [$control]);
+ } else {
+ $controls[LDAP_CONTROL_SORTREQUEST] = $control;
+ }
+ }
+
+ $count = 0;
+ $cookie = '';
+ $entries = array();
+ do {
+ if ($legacyControlHandling) {
+ // Do not request the pagination control as a critical extension, as we want the
+ // server to return results even if the paged search request cannot be satisfied
+ ldap_control_paged_result($ds, $pageSize, false, $cookie);
+ } else {
+ $controls[LDAP_CONTROL_PAGEDRESULTS] = [
+ 'oid' => LDAP_CONTROL_PAGEDRESULTS,
+ 'iscritical' => false, // See above
+ 'value' => [
+ 'size' => $pageSize,
+ 'cookie' => $cookie
+ ]
+ ];
+ }
+
+ $results = $this->ldapSearch(
+ $query,
+ array_values($fields),
+ 0,
+ ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0,
+ 0,
+ LDAP_DEREF_NEVER,
+ empty($controls) ? null : $controls
+ );
+ if ($results === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ break;
+ }
+
+ throw new LdapException(
+ 'LDAP query "%s" (base %s) failed. Error: %s',
+ (string) $query,
+ $query->getBase() ?: $this->getDn(),
+ ldap_error($ds)
+ );
+ } elseif (ldap_count_entries($ds, $results) === 0) {
+ if (in_array(
+ ldap_errno($ds),
+ array(static::LDAP_SIZELIMIT_EXCEEDED, static::LDAP_ADMINLIMIT_EXCEEDED),
+ true
+ )) {
+ Logger::warning(
+ 'Unable to request more than %u results. Does the server allow paged search requests? (%s)',
+ $count,
+ ldap_error($ds)
+ );
+ }
+
+ break;
+ }
+
+ $entry = ldap_first_entry($ds, $results);
+ do {
+ if ($unfoldAttribute) {
+ $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
+ if (is_array($rows)) {
+ // TODO: Register the DN the same way as a section name in the ArrayDatasource!
+ foreach ($rows as $row) {
+ if ($query->getFilter()->matches($row)) {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[] = $row;
+ }
+
+ if ($serverSorting && $limit > 0 && $limit === count($entries)) {
+ break;
+ }
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $rows;
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
+ ldap_get_attributes($ds, $entry),
+ $fields
+ );
+ }
+ }
+ } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
+ && ($entry = ldap_next_entry($ds, $entry))
+ );
+
+ if ($legacyControlHandling) {
+ if (false === @ldap_control_paged_result_response($ds, $results, $cookie)) {
+ // If the page size is greater than or equal to the sizeLimit value, the server should ignore the
+ // control as the request can be satisfied in a single page: https://www.ietf.org/rfc/rfc2696.txt
+ // This applies no matter whether paged search requests are permitted or not. You're done once you
+ // got everything you were out for.
+ if ($serverSorting && count($entries) !== $limit) {
+ // The server does not support pagination, but still returned a response by ignoring the
+ // pagedResultsControl. We output a warning to indicate that the pagination control was ignored.
+ Logger::warning(
+ 'Unable to request paged LDAP results. Does the server allow paged search requests?'
+ );
+ }
+ }
+ } else {
+ ldap_parse_result($ds, $results, $errno, $dn, $errmsg, $refs, $controlsReturned);
+ $cookie = $controlsReturned[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'];
+ }
+
+ ldap_free_result($results);
+ } while ($cookie && (! $serverSorting || $limit === 0 || count($entries) < $limit));
+
+ if ($legacyControlHandling && $cookie) {
+ // A sequence of paged search requests is abandoned by the client sending a search request containing a
+ // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by
+ // the server: https://www.ietf.org/rfc/rfc2696.txt
+ ldap_control_paged_result($ds, 0, false, $cookie);
+ // Returns no entries, due to the page size
+ ldap_search($ds, $query->getBase() ?: $this->getDn(), (string) $query);
+ }
+
+ if (! $serverSorting) {
+ if ($query->hasOrder()) {
+ uasort($entries, array($query, 'compare'));
+ }
+
+ if ($limit && $count > $limit) {
+ $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Clean up the given attributes and return them as simple object
+ *
+ * Applies column aliases, aggregates/unfolds multi-value attributes
+ * as array and sets null for each missing attribute.
+ *
+ * @param array $attributes
+ * @param array $requestedFields
+ * @param string $unfoldAttribute
+ *
+ * @return object|array An array in case the object has been unfolded
+ */
+ public function cleanupAttributes($attributes, array $requestedFields, $unfoldAttribute = null)
+ {
+ // In case the result contains attributes with a differing case than the requested fields, it is
+ // necessary to create another array to map attributes case insensitively to their requested counterparts.
+ // This does also apply the virtual alias handling. (Since an LDAP server does not handle such)
+ $loweredFieldMap = array();
+ foreach ($requestedFields as $alias => $name) {
+ $loweredName = strtolower($name);
+ if (isset($loweredFieldMap[$loweredName])) {
+ if (! is_array($loweredFieldMap[$loweredName])) {
+ $loweredFieldMap[$loweredName] = array($loweredFieldMap[$loweredName]);
+ }
+
+ $loweredFieldMap[$loweredName][] = is_string($alias) ? $alias : $name;
+ } else {
+ $loweredFieldMap[$loweredName] = is_string($alias) ? $alias : $name;
+ }
+ }
+
+ $cleanedAttributes = array();
+ for ($i = 0; $i < $attributes['count']; $i++) {
+ $attribute_name = $attributes[$i];
+ if ($attributes[$attribute_name]['count'] === 1) {
+ $attribute_value = $attributes[$attribute_name][0];
+ } else {
+ $attribute_value = array();
+ for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) {
+ $attribute_value[] = $attributes[$attribute_name][$j];
+ }
+ }
+
+ $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)])
+ ? $loweredFieldMap[strtolower($attribute_name)]
+ : $attribute_name;
+ if (is_array($requestedAttributeName)) {
+ foreach ($requestedAttributeName as $requestedName) {
+ $cleanedAttributes[$requestedName] = $attribute_value;
+ }
+ } else {
+ $cleanedAttributes[$requestedAttributeName] = $attribute_value;
+ }
+ }
+
+ // The result may not contain all requested fields, so populate the cleaned
+ // result with the missing fields and their value being set to null
+ foreach ($requestedFields as $alias => $name) {
+ if (! is_string($alias)) {
+ $alias = $name;
+ }
+
+ if (! array_key_exists($alias, $cleanedAttributes)) {
+ $cleanedAttributes[$alias] = null;
+ Logger::debug('LDAP query result does not provide the requested field "%s"', $name);
+ }
+ }
+
+ if ($unfoldAttribute !== null
+ && isset($cleanedAttributes[$unfoldAttribute])
+ && is_array($cleanedAttributes[$unfoldAttribute])
+ ) {
+ $siblings = array();
+ foreach ($loweredFieldMap as $loweredName => $requestedNames) {
+ if (is_array($requestedNames) && in_array($unfoldAttribute, $requestedNames, true)) {
+ $siblings = array_diff($requestedNames, array($unfoldAttribute));
+ break;
+ }
+ }
+
+ $values = $cleanedAttributes[$unfoldAttribute];
+ unset($cleanedAttributes[$unfoldAttribute]);
+ $baseRow = (object) $cleanedAttributes;
+ $rows = array();
+ foreach ($values as $value) {
+ $row = clone $baseRow;
+ $row->{$unfoldAttribute} = $value;
+ foreach ($siblings as $sibling) {
+ $row->{$sibling} = $value;
+ }
+
+ $rows[] = $row;
+ }
+
+ return $rows;
+ }
+
+ return (object) $cleanedAttributes;
+ }
+
+ /**
+ * Encode the given array of sort rules as ASN.1 octet stream according to RFC 2891
+ *
+ * @param array $sortRules
+ *
+ * @return string Binary representation of the octet stream
+ */
+ protected function encodeSortRules(array $sortRules)
+ {
+ $sequenceOf = '';
+
+ foreach ($sortRules as $rule) {
+ if ($rule[1] === Sortable::SORT_DESC) {
+ $reversed = '8101ff';
+ } else {
+ $reversed = '';
+ }
+
+ $attributeType = unpack('H*', $rule[0]);
+ $attributeType = $attributeType[1];
+ $attributeOctets = strlen($attributeType) / 2;
+ if ($attributeOctets >= 127) {
+ // Use the indefinite form of the length octets (the long form would be another option)
+ $attributeType = '0440' . $attributeType . '0000';
+ } else {
+ $attributeType = '04' . str_pad(dechex($attributeOctets), 2, '0', STR_PAD_LEFT) . $attributeType;
+ }
+
+ $sequence = $attributeType . $reversed;
+ $sequenceOctects = strlen($sequence) / 2;
+ if ($sequenceOctects >= 127) {
+ $sequence = '3040' . $sequence . '0000';
+ } else {
+ $sequence = '30' . str_pad(dechex($sequenceOctects), 2, '0', STR_PAD_LEFT) . $sequence;
+ }
+
+ $sequenceOf .= $sequence;
+ }
+
+ $sequenceOfOctets = strlen($sequenceOf) / 2;
+ if ($sequenceOfOctets >= 127) {
+ $sequenceOf = '3040' . $sequenceOf . '0000';
+ } else {
+ $sequenceOf = '30' . str_pad(dechex($sequenceOfOctets), 2, '0', STR_PAD_LEFT) . $sequenceOf;
+ }
+
+ return hex2bin($sequenceOf);
+ }
+
+ /**
+ * Prepare and establish a connection with the LDAP server
+ *
+ * @param Inspection $info Optional inspection to fill with diagnostic info
+ *
+ * @return resource A LDAP link identifier
+ *
+ * @throws LdapException In case the connection is not possible
+ */
+ protected function prepareNewConnection(Inspection $info = null)
+ {
+ if (! isset($info)) {
+ $info = new Inspection('');
+ }
+
+ $hostname = $this->normalizeHostname($this->hostname);
+
+ $ds = ldap_connect($hostname);
+
+ // Set a proper timeout for each connection
+ ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout);
+
+ // Usage of ldap_rename, setting LDAP_OPT_REFERRALS to 0 or using STARTTLS requires LDAPv3.
+ // If this does not work we're probably not in a PHP 5.3+ environment as it is VERY
+ // unlikely that the server complains about it by itself prior to a bind request
+ ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
+
+ // Not setting this results in "Operations error" on AD when using the whole domain as search base
+ ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);
+
+ if ($this->encryption === static::LDAPS) {
+ $info->write('Connect using LDAPS');
+ } elseif ($this->encryption === static::STARTTLS) {
+ $this->encrypted = true;
+ $info->write('Connect using STARTTLS');
+ if (! ldap_start_tls($ds)) {
+ throw new LdapException('LDAP STARTTLS failed: %s', ldap_error($ds));
+ }
+ } elseif ($this->encryption !== static::LDAPS) {
+ $this->encrypted = false;
+ $info->write('Connect without encryption');
+ }
+
+ return $ds;
+ }
+
+ /**
+ * Perform a LDAP search and return the result
+ *
+ * @param LdapQuery $query
+ * @param array $attributes An array of the required attributes
+ * @param int $attrsonly Should be set to 1 if only attribute types are wanted
+ * @param int $sizelimit Enables you to limit the count of entries fetched
+ * @param int $timelimit Sets the number of seconds how long is spend on the search
+ * @param int $deref
+ * @param array $controls LDAP Controls to send with the request (Only supported with PHP v7.3+)
+ *
+ * @return resource|bool A search result identifier or false on error
+ *
+ * @throws LogicException If the LDAP query search scope is unsupported
+ */
+ public function ldapSearch(
+ LdapQuery $query,
+ array $attributes = null,
+ $attrsonly = 0,
+ $sizelimit = 0,
+ $timelimit = 0,
+ $deref = LDAP_DEREF_NEVER,
+ $controls = null
+ ) {
+ $queryString = (string) $query;
+ $baseDn = $query->getBase() ?: $this->getDn();
+ $scope = $query->getScope();
+
+ if (Logger::getInstance()->getLevel() === Logger::DEBUG) {
+ // We're checking the level by ourselves to avoid rendering the ldapsearch commandline for nothing
+ $starttlsParam = $this->encryption === static::STARTTLS ? ' -ZZ' : '';
+
+ $bindParams = '';
+ if ($this->bound) {
+ $bindParams = ' -D "' . $this->bindDn . '"' . ($this->bindPw ? ' -W' : '');
+ }
+
+ if ($deref === LDAP_DEREF_NEVER) {
+ $derefName = 'never';
+ } elseif ($deref === LDAP_DEREF_ALWAYS) {
+ $derefName = 'always';
+ } elseif ($deref === LDAP_DEREF_SEARCHING) {
+ $derefName = 'search';
+ } else { // $deref === LDAP_DEREF_FINDING
+ $derefName = 'find';
+ }
+
+ Logger::debug("Issuing LDAP search. Use '%s' to reproduce.", sprintf(
+ 'ldapsearch -P 3%s -H "%s"%s -b "%s" -s "%s" -z %u -l %u -a "%s"%s%s%s',
+ $starttlsParam,
+ $this->normalizeHostname($this->hostname),
+ $bindParams,
+ $baseDn,
+ $scope,
+ $sizelimit,
+ $timelimit,
+ $derefName,
+ $attrsonly ? ' -A' : '',
+ $queryString ? ' "' . $queryString . '"' : '',
+ $attributes ? ' "' . join('" "', $attributes) . '"' : ''
+ ));
+ }
+
+ switch ($scope) {
+ case LdapQuery::SCOPE_SUB:
+ $function = 'ldap_search';
+ break;
+ case LdapQuery::SCOPE_ONE:
+ $function = 'ldap_list';
+ break;
+ case LdapQuery::SCOPE_BASE:
+ $function = 'ldap_read';
+ break;
+ default:
+ throw new LogicException('LDAP scope %s not supported by ldapSearch', $scope);
+ }
+
+ // Explicit calls with and without controls,
+ // because the parameter is only supported since PHP 7.3.
+ // Since it is a public method,
+ // providing controls will naturally fail if the parameter is not supported by PHP.
+ if ($controls !== null) {
+ return @$function(
+ $this->getConnection(),
+ $baseDn,
+ $queryString,
+ $attributes,
+ $attrsonly,
+ $sizelimit,
+ $timelimit,
+ $deref,
+ $controls
+ );
+ } else {
+ return @$function(
+ $this->getConnection(),
+ $baseDn,
+ $queryString,
+ $attributes,
+ $attrsonly,
+ $sizelimit,
+ $timelimit,
+ $deref
+ );
+ }
+ }
+
+ /**
+ * Create an LDAP entry
+ *
+ * @param string $dn The distinguished name to use
+ * @param array $attributes The entry's attributes
+ *
+ * @return bool Whether the operation was successful
+ */
+ public function addEntry($dn, array $attributes)
+ {
+ return ldap_add($this->getConnection(), $dn, $attributes);
+ }
+
+ /**
+ * Modify an LDAP entry
+ *
+ * @param string $dn The distinguished name to use
+ * @param array $attributes The attributes to update the entry with
+ *
+ * @return bool Whether the operation was successful
+ */
+ public function modifyEntry($dn, array $attributes)
+ {
+ return ldap_modify($this->getConnection(), $dn, $attributes);
+ }
+
+ /**
+ * Change the distinguished name of an LDAP entry
+ *
+ * @param string $dn The entry's current distinguished name
+ * @param string $newRdn The new relative distinguished name
+ * @param string $newParentDn The new parent or superior entry's distinguished name
+ *
+ * @return resource The resulting search result identifier
+ *
+ * @throws LdapException In case an error occured
+ */
+ public function moveEntry($dn, $newRdn, $newParentDn)
+ {
+ $ds = $this->getConnection();
+ $result = ldap_rename($ds, $dn, $newRdn, $newParentDn, false);
+ if ($result === false) {
+ throw new LdapException('Could not move entry "%s" to "%s": %s', $dn, $newRdn, ldap_error($ds));
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the LDAP specific configuration directory with the given relative path being appended
+ *
+ * @param string $sub
+ *
+ * @return string
+ */
+ protected function getConfigDir($sub = null)
+ {
+ $dir = Config::$configDir . '/ldap';
+ if ($sub !== null) {
+ $dir .= '/' . $sub;
+ }
+
+ return $dir;
+ }
+
+ /**
+ * Render and return a valid LDAP filter representation of the given filter
+ *
+ * @param Filter $filter
+ * @param int $level
+ *
+ * @return string
+ */
+ public function renderFilter(Filter $filter, $level = 0)
+ {
+ if ($filter->isExpression()) {
+ /** @var $filter FilterExpression */
+ return $this->renderFilterExpression($filter);
+ }
+
+ /** @var $filter FilterChain */
+ $parts = array();
+ foreach ($filter->filters() as $filterPart) {
+ $part = $this->renderFilter($filterPart, $level + 1);
+ if ($part) {
+ $parts[] = $part;
+ }
+ }
+
+ if (empty($parts)) {
+ return '';
+ }
+
+ $format = '%1$s(%2$s)';
+ if (count($parts) === 1 && ! $filter instanceof FilterNot) {
+ $format = '%2$s';
+ }
+ if ($level === 0) {
+ $format = '(' . $format . ')';
+ }
+
+ return sprintf($format, $filter->getOperatorSymbol(), implode(')(', $parts));
+ }
+
+ /**
+ * Render and return a valid LDAP filter expression of the given filter
+ *
+ * @param FilterExpression $filter
+ *
+ * @return string
+ */
+ protected function renderFilterExpression(FilterExpression $filter)
+ {
+ $column = $filter->getColumn();
+ $sign = $filter->getSign();
+ $expression = $filter->getExpression();
+ $format = '%1$s%2$s%3$s';
+
+ if ($expression === null || $expression === true) {
+ $expression = '*';
+ } elseif (is_array($expression)) {
+ $seqFormat = '|(%s)';
+ if ($sign === '!=') {
+ $seqFormat = '!(' . $seqFormat . ')';
+ $sign = '=';
+ }
+
+ $seqParts = array();
+ foreach ($expression as $expressionValue) {
+ $seqParts[] = sprintf(
+ $format,
+ LdapUtils::quoteForSearch($column),
+ $sign,
+ LdapUtils::quoteForSearch($expressionValue, true)
+ );
+ }
+
+ return sprintf($seqFormat, implode(')(', $seqParts));
+ }
+
+ if ($sign === '!=') {
+ $format = '!(%1$s=%3$s)';
+ }
+
+ return sprintf(
+ $format,
+ LdapUtils::quoteForSearch($column),
+ $sign,
+ LdapUtils::quoteForSearch($expression, true)
+ );
+ }
+
+ /**
+ * Inspect if this LDAP Connection is working as expected
+ *
+ * Check if connection, bind and encryption is working as expected and get additional
+ * information about the used
+ *
+ * @return Inspection Inspection result
+ */
+ public function inspect()
+ {
+ $insp = new Inspection('Ldap Connection');
+
+ // Try to connect to the server with the given connection parameters
+ try {
+ $ds = $this->prepareNewConnection($insp);
+ } catch (Exception $e) {
+ if ($this->encryption === 'starttls') {
+ // The Exception does not return any proper error messages in case of certificate errors. Connecting
+ // by STARTTLS will usually fail at this point when the certificate is unknown,
+ // so at least try to give some hints.
+ $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
+ 'supports STARTTLS and that the LDAP-Client is configured to accept its certificate.');
+ }
+ return $insp->error($e->getMessage());
+ }
+
+ // Try a bind-command with the given user credentials, this must not fail
+ $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
+ $msg = sprintf(
+ 'LDAP bind (%s / %s) to %s',
+ $this->bindDn,
+ '***' /* $this->bindPw */,
+ $this->normalizeHostname($this->hostname)
+ );
+ if (! $success) {
+ // ldap_error does not return any proper error messages in case of certificate errors. Connecting
+ // by LDAPS will usually fail at this point when the certificate is unknown, so at least try to give
+ // some hints.
+ if ($this->encryption === 'ldaps') {
+ $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
+ ' supports LDAPS and that the LDAP-Client is configured to accept its certificate.');
+ }
+ return $insp->error(sprintf('%s failed: %s', $msg, ldap_error($ds)));
+ }
+ $insp->write(sprintf($msg . ' successful'));
+
+ // Try to execute a schema discovery this may fail if schema discovery is not supported
+ try {
+ $cap = LdapCapabilities::discoverCapabilities($this);
+ $discovery = new Inspection('Discovery Results');
+ $vendor = $cap->getVendor();
+ if (isset($vendor)) {
+ $discovery->write($vendor);
+ }
+ $version = $cap->getVersion();
+ if (isset($version)) {
+ $discovery->write($version);
+ }
+ $discovery->write('Supports STARTTLS: ' . ($cap->hasStartTls() ? 'True' : 'False'));
+ $discovery->write('Default naming context: ' . $cap->getDefaultNamingContext());
+ $insp->write($discovery);
+ } catch (Exception $e) {
+ $insp->write('Schema discovery not possible: ' . $e->getMessage());
+ }
+ return $insp;
+ }
+
+ protected function normalizeHostname($hostname)
+ {
+ $scheme = $this->encryption === static::LDAPS ? 'ldaps://' : 'ldap://';
+ $normalizeHostname = function ($hostname) use ($scheme) {
+ if (strpos($hostname, $scheme) === false) {
+ $hostname = $scheme . $hostname;
+ }
+
+ if (! preg_match('/:\d+$/', $hostname)) {
+ $hostname .= ':' . $this->port;
+ }
+
+ return $hostname;
+ };
+
+ $ldapUrls = explode(' ', $hostname);
+ if (count($ldapUrls) > 1) {
+ foreach ($ldapUrls as & $uri) {
+ $uri = $normalizeHostname($uri);
+ }
+
+ $hostname = implode(' ', $ldapUrls);
+ } else {
+ $hostname = $normalizeHostname($hostname);
+ }
+
+ return $hostname;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapException.php b/library/Icinga/Protocol/Ldap/LdapException.php
new file mode 100644
index 0000000..740ee29
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapException.php
@@ -0,0 +1,14 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Class LdapException
+ * @package Icinga\Protocol\Ldap
+ */
+class LdapException extends IcingaException
+{
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapQuery.php b/library/Icinga/Protocol/Ldap/LdapQuery.php
new file mode 100644
index 0000000..f4e1986
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapQuery.php
@@ -0,0 +1,361 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Data\Filter\Filter;
+use LogicException;
+use Icinga\Data\SimpleQuery;
+
+/**
+ * LDAP query class
+ */
+class LdapQuery extends SimpleQuery
+{
+ /**
+ * The base dn being used for this query
+ *
+ * @var string
+ */
+ protected $base;
+
+ /**
+ * Whether this query is permitted to utilize paged results
+ *
+ * @var bool
+ */
+ protected $usePagedResults;
+
+ /**
+ * The name of the attribute used to unfold the result
+ *
+ * @var string
+ */
+ protected $unfoldAttribute;
+
+ /**
+ * This query's native LDAP filter
+ *
+ * @var string
+ */
+ protected $nativeFilter;
+
+ /**
+ * Only fetch the entry at the base of the search
+ */
+ const SCOPE_BASE = 'base';
+
+ /**
+ * Fetch entries one below the base DN
+ */
+ const SCOPE_ONE = 'one';
+
+ /**
+ * Fetch all entries below the base DN
+ */
+ const SCOPE_SUB = 'sub';
+
+ /**
+ * All available scopes
+ *
+ * @var array
+ */
+ public static $scopes = array(
+ LdapQuery::SCOPE_BASE,
+ LdapQuery::SCOPE_ONE,
+ LdapQuery::SCOPE_SUB
+ );
+
+ /**
+ * LDAP search scope (default: SCOPE_SUB)
+ *
+ * @var string
+ */
+ protected $scope = LdapQuery::SCOPE_SUB;
+
+ /**
+ * Initialize this query
+ */
+ protected function init()
+ {
+ $this->usePagedResults = false;
+ }
+
+ /**
+ * Set the base dn to be used for this query
+ *
+ * @param string $base
+ *
+ * @return $this
+ */
+ public function setBase($base)
+ {
+ $this->base = $base;
+ return $this;
+ }
+
+ /**
+ * Return the base dn being used for this query
+ *
+ * @return string
+ */
+ public function getBase()
+ {
+ return $this->base;
+ }
+
+ /**
+ * Set whether this query is permitted to utilize paged results
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setUsePagedResults($state = true)
+ {
+ $this->usePagedResults = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether this query is permitted to utilize paged results
+ *
+ * @return bool
+ */
+ public function getUsePagedResults()
+ {
+ return $this->usePagedResults;
+ }
+
+ /**
+ * Set the attribute to be used to unfold the result
+ *
+ * @param string $attributeName
+ *
+ * @return $this
+ */
+ public function setUnfoldAttribute($attributeName)
+ {
+ $this->unfoldAttribute = $attributeName;
+ return $this;
+ }
+
+ /**
+ * Return the attribute to use to unfold the result
+ *
+ * @return string
+ */
+ public function getUnfoldAttribute()
+ {
+ return $this->unfoldAttribute;
+ }
+
+ /**
+ * Set this query's native LDAP filter
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setNativeFilter($filter)
+ {
+ $this->nativeFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Return this query's native LDAP filter
+ *
+ * @return string
+ */
+ public function getNativeFilter()
+ {
+ return $this->nativeFilter;
+ }
+
+ /**
+ * Choose an objectClass and the columns you are interested in
+ *
+ * {@inheritdoc} This creates an objectClass filter.
+ */
+ public function from($target, array $fields = null)
+ {
+ $this->where('objectClass', $target);
+ return parent::from($target, $fields);
+ }
+
+ public function where($condition, $value = null)
+ {
+ $this->addFilter(Filter::expression($condition, '=', $value));
+ return $this;
+ }
+
+ public function addFilter(Filter $filter)
+ {
+ $this->makeCaseInsensitive($filter);
+ return parent::addFilter($filter);
+ }
+
+ public function setFilter(Filter $filter)
+ {
+ $this->makeCaseInsensitive($filter);
+ return parent::setFilter($filter);
+ }
+
+ protected function makeCaseInsensitive(Filter $filter)
+ {
+ if ($filter->isExpression()) {
+ /** @var \Icinga\Data\Filter\FilterExpression $filter */
+ $filter->setCaseSensitive(false);
+ } else {
+ /** @var \Icinga\Data\Filter\FilterChain $filter */
+ foreach ($filter->filters() as $subFilter) {
+ $this->makeCaseInsensitive($subFilter);
+ }
+ }
+ }
+
+ public function compare($a, $b, $orderIndex = 0)
+ {
+ if (array_key_exists($orderIndex, $this->order)) {
+ $column = $this->order[$orderIndex][0];
+ $direction = $this->order[$orderIndex][1];
+
+ $flippedColumns = $this->flippedColumns ?: array_flip($this->columns);
+ if (array_key_exists($column, $flippedColumns) && is_string($flippedColumns[$column])) {
+ $column = $flippedColumns[$column];
+ }
+
+ if (is_array($a->$column)) {
+ // rfc2891 states: If a sort key is a multi-valued attribute, and an entry happens to
+ // have multiple values for that attribute and no other controls are
+ // present that affect the sorting order, then the server SHOULD use the
+ // least value (according to the ORDERING rule for that attribute).
+ $a = clone $a;
+ $a->$column = array_reduce($a->$column, function ($carry, $item) use ($direction) {
+ $result = $carry === null ? 0 : strcmp($item, $carry);
+ return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry;
+ });
+ }
+
+ if (is_array($b->$column)) {
+ $b = clone $b;
+ $b->$column = array_reduce($b->$column, function ($carry, $item) use ($direction) {
+ $result = $carry === null ? 0 : strcmp($item, $carry);
+ return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry;
+ });
+ }
+ }
+
+ return parent::compare($a, $b, $orderIndex);
+ }
+
+ /**
+ * Fetch result as tree
+ *
+ * @return Root
+ *
+ * @todo This is untested waste, not being used anywhere and ignores the query's order and base dn.
+ * Evaluate whether it's reasonable to properly implement and test it.
+ */
+ public function fetchTree()
+ {
+ $result = $this->fetchAll();
+ $sorted = array();
+ $quotedDn = preg_quote($this->ds->getDn(), '/');
+ foreach ($result as $key => & $item) {
+ $new_key = LdapUtils::implodeDN(
+ array_reverse(
+ LdapUtils::explodeDN(
+ preg_replace('/,' . $quotedDn . '$/', '', $key)
+ )
+ )
+ );
+ $sorted[$new_key] = $key;
+ }
+
+ ksort($sorted);
+
+ $tree = Root::forConnection($this->ds);
+ $root_dn = $tree->getDN();
+ foreach ($sorted as $sort_key => & $key) {
+ if ($key === $root_dn) {
+ continue;
+ }
+ $tree->createChildByDN($key, $result[$key]);
+ }
+ return $tree;
+ }
+
+ /**
+ * Fetch the distinguished name of the first result
+ *
+ * @return string|false The distinguished name or false in case it's not possible to fetch a result
+ *
+ * @throws LdapException In case the query returns multiple results
+ * (i.e. it's not possible to fetch a unique DN)
+ */
+ public function fetchDn()
+ {
+ return $this->ds->fetchDn($this);
+ }
+
+ /**
+ * Render and return this query's filter
+ *
+ * @return string
+ */
+ public function renderFilter()
+ {
+ $filter = $this->ds->renderFilter($this->filter);
+ if ($this->nativeFilter) {
+ $filter = '(&(' . $this->nativeFilter . ')' . $filter . ')';
+ }
+
+ return $filter;
+ }
+
+ /**
+ * Return the LDAP filter to be applied on this query
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->renderFilter();
+ }
+
+ /**
+ * Get LDAP search scope
+ *
+ * @return string
+ */
+ public function getScope()
+ {
+ return $this->scope;
+ }
+
+ /**
+ * Set LDAP search scope
+ *
+ * Valid: sub one base (Default: sub)
+ *
+ * @param string $scope
+ *
+ * @return LdapQuery
+ *
+ * @throws LogicException If scope value is invalid
+ */
+ public function setScope($scope)
+ {
+ if (! in_array($scope, static::$scopes)) {
+ throw new LogicException(
+ 'Can\'t set scope %d, it is is invalid. Use one of %s or LdapQuery\'s constants.',
+ $scope,
+ implode(', ', static::$scopes)
+ );
+ }
+ $this->scope = $scope;
+ return $this;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapUtils.php b/library/Icinga/Protocol/Ldap/LdapUtils.php
new file mode 100644
index 0000000..9c9ae10
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapUtils.php
@@ -0,0 +1,148 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+/**
+ * This class provides useful LDAP-related functions
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @package Icinga\Protocol\Ldap
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+class LdapUtils
+{
+ /**
+ * Extends PHPs ldap_explode_dn() function
+ *
+ * UTF-8 chars like German umlauts would otherwise be escaped and shown
+ * as backslash-prefixed hexcode-sequenzes.
+ *
+ * @param string $dn DN
+ * @param boolean $with_type Returns 'type=value' when true and 'value' when false
+ *
+ * @return array
+ */
+ public static function explodeDN($dn, $with_type = true)
+ {
+ $res = ldap_explode_dn($dn, $with_type ? 0 : 1);
+
+ foreach ($res as $k => $v) {
+ $res[$k] = preg_replace_callback(
+ '/\\\([0-9a-f]{2})/i',
+ function ($m) {
+ return chr(hexdec($m[1]));
+ },
+ $v
+ );
+ }
+ unset($res['count']);
+ return $res;
+ }
+
+ /**
+ * Implode unquoted RDNs to a DN
+ *
+ * TODO: throw away, this is not how it shall be done
+ *
+ * @param array $parts DN-component
+ *
+ * @return string
+ */
+ public static function implodeDN($parts)
+ {
+ $str = '';
+ foreach ($parts as $part) {
+ if ($str !== '') {
+ $str .= ',';
+ }
+ list($key, $val) = preg_split('~=~', $part, 2);
+ $str .= $key . '=' . self::quoteForDN($val);
+ }
+ return $str;
+ }
+
+ /**
+ * Test if supplied value looks like a DN
+ *
+ * @param mixed $value
+ *
+ * @return bool
+ */
+ public static function isDn($value)
+ {
+ if (is_string($value)) {
+ return ldap_dn2ufn($value) !== false;
+ }
+ return false;
+ }
+
+ /**
+ * Quote a string that should be used in a DN
+ *
+ * Special characters will be escaped
+ *
+ * @param string $str DN-component
+ *
+ * @return string
+ */
+ public static function quoteForDN($str)
+ {
+ return self::quoteChars(
+ $str,
+ array(
+ ',',
+ '=',
+ '+',
+ '<',
+ '>',
+ ';',
+ '\\',
+ '"',
+ '#'
+ )
+ );
+ }
+
+ /**
+ * Quote a string that should be used in an LDAP search
+ *
+ * Special characters will be escaped
+ *
+ * @param string String to be escaped
+ * @param bool $allow_wildcard
+ * @return string
+ */
+ public static function quoteForSearch($str, $allow_wildcard = false)
+ {
+ if ($allow_wildcard) {
+ return self::quoteChars($str, array('(', ')', '\\', chr(0)));
+ }
+ return self::quoteChars($str, array('*', '(', ')', '\\', chr(0)));
+ }
+
+ /**
+ * Escape given characters in the given string
+ *
+ * Special characters will be escaped
+ *
+ * @param $str
+ * @param $chars
+ * @internal param String $string to be escaped
+ * @return string
+ */
+ protected static function quoteChars($str, $chars)
+ {
+ $quotedChars = array();
+ foreach ($chars as $k => $v) {
+ // Temporarily prefixing with illegal '('
+ $quotedChars[$k] = '(' . str_pad(dechex(ord($v)), 2, '0');
+ }
+ $str = str_replace($chars, $quotedChars, $str);
+ // Replacing temporary '(' with '\\'. This is a workaround, as
+ // str_replace behaves pretty strange with leading a backslash:
+ $str = preg_replace('~\(~', '\\', $str);
+ return $str;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/Node.php b/library/Icinga/Protocol/Ldap/Node.php
new file mode 100644
index 0000000..176f962
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/Node.php
@@ -0,0 +1,69 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+/**
+ * This class represents an LDAP node object
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @package Icinga\Protocol\Ldap
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+class Node extends Root
+{
+ /**
+ * @var LdapConnection
+ */
+ protected $connection;
+
+ /**
+ * @var
+ */
+ protected $rdn;
+
+ /**
+ * @var Root
+ */
+ protected $parent;
+
+ /**
+ * @param Root $parent
+ */
+ protected function __construct(Root $parent)
+ {
+ $this->connection = $parent->getConnection();
+ $this->parent = $parent;
+ }
+
+ /**
+ * @param $parent
+ * @param $rdn
+ * @param array $props
+ * @return Node
+ */
+ public static function createWithRDN($parent, $rdn, $props = array())
+ {
+ $node = new Node($parent);
+ $node->rdn = $rdn;
+ $node->props = $props;
+ return $node;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getRDN()
+ {
+ return $this->rdn;
+ }
+
+ /**
+ * @return mixed|string
+ */
+ public function getDN()
+ {
+ return $this->getRDN() . ',' . $this->parent->getDN();
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/Root.php b/library/Icinga/Protocol/Ldap/Root.php
new file mode 100644
index 0000000..48d8719
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/Root.php
@@ -0,0 +1,242 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * This class is a special node object, representing your connections root node
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @package Icinga\Protocol\Ldap
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ * @package Icinga\Protocol\Ldap
+ */
+class Root
+{
+ /**
+ * @var string
+ */
+ protected $rdn;
+
+ /**
+ * @var LdapConnection
+ */
+ protected $connection;
+
+ /**
+ * @var array
+ */
+ protected $children = array();
+
+ /**
+ * @var array
+ */
+ protected $props = array();
+
+ /**
+ * @param LdapConnection $connection
+ */
+ protected function __construct(LdapConnection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasParent()
+ {
+ return false;
+ }
+
+ /**
+ * @param LdapConnection $connection
+ * @return Root
+ */
+ public static function forConnection(LdapConnection $connection)
+ {
+ $root = new Root($connection);
+ return $root;
+ }
+
+ /**
+ * @param $dn
+ * @param array $props
+ * @return Node
+ */
+ public function createChildByDN($dn, $props = array())
+ {
+ $dn = $this->stripMyDN($dn);
+ $parts = array_reverse(LdapUtils::explodeDN($dn));
+ $parent = $this;
+ $child = null;
+ while ($rdn = array_shift($parts)) {
+ if ($parent->hasChildRDN($rdn)) {
+ $child = $parent->getChildByRDN($rdn);
+ } else {
+ $child = Node::createWithRDN($parent, $rdn, (array)$props);
+ $parent->addChild($child);
+ }
+ $parent = $child;
+ }
+ return $child;
+ }
+
+ /**
+ * @param $rdn
+ * @return bool
+ */
+ public function hasChildRDN($rdn)
+ {
+ return array_key_exists(strtolower($rdn), $this->children);
+ }
+
+ /**
+ * @param $rdn
+ * @return mixed
+ * @throws IcingaException
+ */
+ public function getChildByRDN($rdn)
+ {
+ if (!$this->hasChildRDN($rdn)) {
+ throw new IcingaException(
+ 'The child RDN "%s" is not available',
+ $rdn
+ );
+ }
+ return $this->children[strtolower($rdn)];
+ }
+
+ /**
+ * @return array
+ */
+ public function children()
+ {
+ return $this->children;
+ }
+
+ public function countChildren()
+ {
+ return count($this->children);
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasChildren()
+ {
+ return !empty($this->children);
+ }
+
+ /**
+ * @param Node $child
+ * @return $this
+ */
+ public function addChild(Node $child)
+ {
+ $this->children[strtolower($child->getRDN())] = $child;
+ return $this;
+ }
+
+ /**
+ * @param $dn
+ * @return string
+ */
+ protected function stripMyDN($dn)
+ {
+ $this->assertSubDN($dn);
+ return substr($dn, 0, strlen($dn) - strlen($this->getDN()) - 1);
+ }
+
+ /**
+ * @param $dn
+ * @return $this
+ * @throws IcingaException
+ */
+ protected function assertSubDN($dn)
+ {
+ $mydn = $this->getDN();
+ $end = substr($dn, -1 * strlen($mydn));
+ if (strtolower($end) !== strtolower($mydn)) {
+ throw new IcingaException(
+ '"%s" is not a child of "%s"',
+ $dn,
+ $mydn
+ );
+ }
+ if (strlen($dn) === strlen($mydn)) {
+ throw new IcingaException(
+ '"%s" is not a child of "%s", they are equal',
+ $dn,
+ $mydn
+ );
+ }
+ return $this;
+ }
+
+ /**
+ * @param LdapConnection $connection
+ * @return $this
+ */
+ public function setConnection(LdapConnection $connection)
+ {
+ $this->connection = $connection;
+ return $this;
+ }
+
+ /**
+ * @return LdapConnection
+ */
+ public function getConnection()
+ {
+ return $this->connection;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasBeenChanged()
+ {
+ return false;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getRDN()
+ {
+ return $this->getDN();
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getDN()
+ {
+ return $this->connection->getDn();
+ }
+
+ /**
+ * @param $key
+ * @return null
+ */
+ public function __get($key)
+ {
+ if (!array_key_exists($key, $this->props)) {
+ return null;
+ }
+ return $this->props[$key];
+ }
+
+ /**
+ * @param $key
+ * @return bool
+ */
+ public function __isset($key)
+ {
+ return array_key_exists($key, $this->props);
+ }
+}
diff --git a/library/Icinga/Protocol/Nrpe/Connection.php b/library/Icinga/Protocol/Nrpe/Connection.php
new file mode 100644
index 0000000..491a965
--- /dev/null
+++ b/library/Icinga/Protocol/Nrpe/Connection.php
@@ -0,0 +1,111 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Nrpe;
+
+use Icinga\Exception\IcingaException;
+
+class Connection
+{
+ protected $host;
+ protected $port;
+ protected $connection;
+ protected $use_ssl = false;
+ protected $lastReturnCode = null;
+
+ public function __construct($host, $port = 5666)
+ {
+ $this->host = $host;
+ $this->port = $port;
+ }
+
+ public function useSsl($use_ssl = true)
+ {
+ $this->use_ssl = $use_ssl;
+ return $this;
+ }
+
+ public function sendCommand($command, $args = null)
+ {
+ if (! empty($args)) {
+ $command .= '!' . implode('!', $args);
+ }
+
+ $packet = Packet::createQuery($command);
+ return $this->send($packet);
+ }
+
+ public function getLastReturnCode()
+ {
+ return $this->lastReturnCode;
+ }
+
+ public function send(Packet $packet)
+ {
+ $conn = $this->connection();
+ $bytes = $packet->getBinary();
+ fputs($conn, $bytes, strlen($bytes));
+ // TODO: Check result checksum!
+ $result = fread($conn, 8192);
+ if ($result === false) {
+ throw new IcingaException('CHECK_NRPE: Error receiving data from daemon.');
+ } elseif (strlen($result) === 0) {
+ throw new IcingaException(
+ 'CHECK_NRPE: Received 0 bytes from daemon. Check the remote server logs for error messages'
+ );
+ }
+ // TODO: CHECK_NRPE: Receive underflow - only %d bytes received (%d expected)
+ $code = unpack('n', substr($result, 8, 2));
+ $this->lastReturnCode = $code[1];
+ $this->disconnect();
+ return rtrim(substr($result, 10, -2));
+ }
+
+ protected function connect()
+ {
+ $ctx = stream_context_create();
+ if ($this->use_ssl) {
+ // TODO: fail if not ok:
+ $res = stream_context_set_option($ctx, 'ssl', 'ciphers', 'ADH');
+ $uri = sprintf('ssl://%s:%d', $this->host, $this->port);
+ } else {
+ $uri = sprintf('tcp://%s:%d', $this->host, $this->port);
+ }
+ $this->connection = @stream_socket_client(
+ $uri,
+ $errno,
+ $errstr,
+ 10,
+ STREAM_CLIENT_CONNECT,
+ $ctx
+ );
+ if (! $this->connection) {
+ throw new IcingaException(
+ 'NRPE Connection failed: %s',
+ $errstr
+ );
+ }
+ }
+
+ protected function connection()
+ {
+ if ($this->connection === null) {
+ $this->connect();
+ }
+ return $this->connection;
+ }
+
+ protected function disconnect()
+ {
+ if (is_resource($this->connection)) {
+ fclose($this->connection);
+ $this->connection = null;
+ }
+ return $this;
+ }
+
+ public function __destruct()
+ {
+ $this->disconnect();
+ }
+}
diff --git a/library/Icinga/Protocol/Nrpe/Packet.php b/library/Icinga/Protocol/Nrpe/Packet.php
new file mode 100644
index 0000000..54c8526
--- /dev/null
+++ b/library/Icinga/Protocol/Nrpe/Packet.php
@@ -0,0 +1,69 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Nrpe;
+
+class Packet
+{
+ const QUERY = 0x01;
+ const RESPONSE = 0x02;
+
+ protected $version = 0x02;
+ protected $type;
+ protected $body;
+ protected static $randomBytes;
+
+ public function __construct($type, $body)
+ {
+ $this->type = $type;
+ $this->body = $body;
+ $this->regenerateRandomBytes();
+ }
+
+ // TODO: renew "from time to time" to allow long-running daemons
+ protected function regenerateRandomBytes()
+ {
+ self::$randomBytes = '';
+ for ($i = 0; $i < 4096; $i++) {
+ self::$randomBytes .= pack('N', mt_rand());
+ }
+ }
+
+ public static function createQuery($body)
+ {
+ $packet = new Packet(self::QUERY, $body);
+ return $packet;
+ }
+
+ protected function getFillString($length)
+ {
+ $max = strlen(self::$randomBytes) - $length;
+ return substr(self::$randomBytes, rand(0, $max), $length);
+ }
+
+ // TODO: WTF is SR? And 2324?
+ public function getBinary()
+ {
+ $version = pack('n', $this->version);
+ $type = pack('n', $this->type);
+ $dummycrc = "\x00\x00\x00\x00";
+ $result = "\x00\x00";
+ $result = pack('n', 2324);
+ $body = $this->body
+ . "\x00"
+ . $this->getFillString(1023 - strlen($this->body))
+ . 'SR';
+
+ $crc = pack(
+ 'N',
+ crc32($version . $type . $dummycrc . $result . $body)
+ );
+ $bytes = $version . $type . $crc . $result . $body;
+ return $bytes;
+ }
+
+ public function __toString()
+ {
+ return $this->body;
+ }
+}
diff --git a/library/Icinga/Repository/DbRepository.php b/library/Icinga/Repository/DbRepository.php
new file mode 100644
index 0000000..3f8b604
--- /dev/null
+++ b/library/Icinga/Repository/DbRepository.php
@@ -0,0 +1,1078 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Repository;
+
+use Icinga\Exception\QueryException;
+use Zend_Db_Expr;
+use Icinga\Data\Db\DbConnection;
+use Icinga\Data\Extensible;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Reducible;
+use Icinga\Data\Updatable;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\StatementException;
+use Icinga\Util\StringHelper;
+
+/**
+ * Abstract base class for concrete database repository implementations
+ *
+ * Additionally provided features:
+ * <ul>
+ * <li>Support for table aliases</li>
+ * <li>Automatic table prefix handling</li>
+ * <li>Insert, update and delete capabilities</li>
+ * <li>Differentiation between statement and query columns</li>
+ * <li>Capability to join additional tables depending on the columns being selected or used in a filter</li>
+ * </ul>
+ *
+ * @method DbConnection getDataSource($table = null)
+ */
+abstract class DbRepository extends Repository implements Extensible, Updatable, Reducible
+{
+ /**
+ * The datasource being used
+ *
+ * @var DbConnection
+ */
+ protected $ds;
+
+ /**
+ * The table aliases being applied
+ *
+ * This must be initialized by repositories which are going to make use of table aliases. Every table for which
+ * aliased columns are provided must be defined in this array using its name as key and the alias being used as
+ * value. Failure to do so will result in invalid queries.
+ *
+ * @var array
+ */
+ protected $tableAliases;
+
+ /**
+ * The join probability rules
+ *
+ * This may be initialized by repositories which make use of the table join capability. It allows to define
+ * probability rules to enhance control how ambiguous column aliases are associated with the correct table.
+ * To define a rule use the name of a base table as key and another array of table names as probable join
+ * targets ordered by priority. (Ascending: Lower means higher priority)
+ * <code>
+ * array(
+ * 'table_name' => array('target1', 'target2', 'target3')
+ * )
+ * </code>
+ *
+ * @todo Support for tree-ish rules
+ *
+ * @var array
+ */
+ protected $joinProbabilities;
+
+ /**
+ * The statement columns being provided
+ *
+ * This may be initialized by repositories which are going to make use of table aliases. It allows to provide
+ * alias-less column names to be used for a statement. The array needs to be in the following format:
+ * <code>
+ * array(
+ * 'table_name' => array(
+ * 'column1',
+ * 'alias1' => 'column2',
+ * 'alias2' => 'column3'
+ * )
+ * )
+ * </code>
+ *
+ * @var array
+ */
+ protected $statementColumns;
+
+ /**
+ * An array to map table names to statement columns/aliases
+ *
+ * @var array
+ */
+ protected $statementAliasTableMap;
+
+ /**
+ * A flattened array to map statement columns to aliases
+ *
+ * @var array
+ */
+ protected $statementAliasColumnMap;
+
+ /**
+ * An array to map table names to statement columns
+ *
+ * @var array
+ */
+ protected $statementColumnTableMap;
+
+ /**
+ * A flattened array to map aliases to statement columns
+ *
+ * @var array
+ */
+ protected $statementColumnAliasMap;
+
+ /**
+ * List of column names or aliases mapped to their table where the COLLATE SQL-instruction has been removed
+ *
+ * This list is being populated in case of a PostgreSQL backend only,
+ * to ensure case-insensitive string comparison in WHERE clauses.
+ *
+ * @var array
+ */
+ protected $caseInsensitiveColumns;
+
+ /**
+ * Create a new DB repository object
+ *
+ * In case $this->queryColumns has already been initialized, this initializes
+ * $this->caseInsensitiveColumns in case of a PostgreSQL connection.
+ *
+ * @param DbConnection $ds The datasource to use
+ */
+ public function __construct(DbConnection $ds)
+ {
+ parent::__construct($ds);
+
+ if ($ds->getDbType() === 'pgsql' && $this->queryColumns !== null) {
+ $this->queryColumns = $this->removeCollateInstruction($this->queryColumns);
+ }
+ }
+
+ /**
+ * Return the query columns being provided
+ *
+ * Initializes $this->caseInsensitiveColumns in case of a PostgreSQL connection.
+ *
+ * @return array
+ */
+ public function getQueryColumns()
+ {
+ if ($this->queryColumns === null) {
+ $this->queryColumns = parent::getQueryColumns();
+ if ($this->ds->getDbType() === 'pgsql') {
+ $this->queryColumns = $this->removeCollateInstruction($this->queryColumns);
+ }
+ }
+
+ return $this->queryColumns;
+ }
+
+ /**
+ * Return the table aliases to be applied
+ *
+ * Calls $this->initializeTableAliases() in case $this->tableAliases is null.
+ *
+ * @return array
+ */
+ public function getTableAliases()
+ {
+ if ($this->tableAliases === null) {
+ $this->tableAliases = $this->initializeTableAliases();
+ }
+
+ return $this->tableAliases;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the table aliases lazily
+ *
+ * @return array
+ */
+ protected function initializeTableAliases()
+ {
+ return array();
+ }
+
+ /**
+ * Return the join probability rules
+ *
+ * Calls $this->initializeJoinProbabilities() in case $this->joinProbabilities is null.
+ *
+ * @return array
+ */
+ public function getJoinProbabilities()
+ {
+ if ($this->joinProbabilities === null) {
+ $this->joinProbabilities = $this->initializeJoinProbabilities();
+ }
+
+ return $this->joinProbabilities;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the join probabilities lazily
+ *
+ * @return array
+ */
+ protected function initializeJoinProbabilities()
+ {
+ return array();
+ }
+
+ /**
+ * Remove each COLLATE SQL-instruction from all given query columns
+ *
+ * @param array $queryColumns
+ *
+ * @return array $queryColumns, the updated version
+ */
+ protected function removeCollateInstruction($queryColumns)
+ {
+ foreach ($queryColumns as $table => & $columns) {
+ foreach ($columns as $alias => & $column) {
+ // Using a regex here because COLLATE may occur anywhere in the string
+ $column = preg_replace('/ COLLATE .+$/', '', $column, -1, $count);
+ if ($count > 0) {
+ $this->caseInsensitiveColumns[$table][is_string($alias) ? $alias : $column] = true;
+ }
+ }
+ }
+
+ return $queryColumns;
+ }
+
+ /**
+ * Initialize table, column and alias maps
+ *
+ * @throws ProgrammingError In case $this->queryColumns does not provide any column information
+ */
+ protected function initializeAliasMaps()
+ {
+ parent::initializeAliasMaps();
+
+ foreach ($this->aliasTableMap as $alias => $table) {
+ if ($table !== null) {
+ if (strpos($alias, '.') !== false) {
+ $prefixedAlias = str_replace('.', '_', $alias);
+ } else {
+ $prefixedAlias = $table . '_' . $alias;
+ }
+
+ if (array_key_exists($prefixedAlias, $this->aliasTableMap)) {
+ if ($this->aliasTableMap[$prefixedAlias] !== null) {
+ $existingTable = $this->aliasTableMap[$prefixedAlias];
+ $existingColumn = $this->aliasColumnMap[$prefixedAlias];
+ $this->aliasTableMap[$existingTable . '.' . $prefixedAlias] = $existingTable;
+ $this->aliasColumnMap[$existingTable . '.' . $prefixedAlias] = $existingColumn;
+ $this->aliasTableMap[$prefixedAlias] = null;
+ $this->aliasColumnMap[$prefixedAlias] = null;
+ }
+
+ $this->aliasTableMap[$table . '.' . $prefixedAlias] = $table;
+ $this->aliasColumnMap[$table . '.' . $prefixedAlias] = $this->aliasColumnMap[$alias];
+ } else {
+ $this->aliasTableMap[$prefixedAlias] = $table;
+ $this->aliasColumnMap[$prefixedAlias] = $this->aliasColumnMap[$alias];
+ }
+ }
+ }
+ }
+
+ /**
+ * Return the given table with the datasource's prefix being prepended
+ *
+ * @param array|string $table
+ *
+ * @return array|string
+ *
+ * @throws IcingaException In case $table is not of a supported type
+ */
+ protected function prependTablePrefix($table)
+ {
+ $prefix = $this->ds->getTablePrefix();
+ if (! $prefix) {
+ return $table;
+ }
+
+ if (is_array($table)) {
+ foreach ($table as & $tableName) {
+ if (strpos($tableName, $prefix) === false) {
+ $tableName = $prefix . $tableName;
+ }
+ }
+ } elseif (is_string($table)) {
+ $table = (strpos($table, $prefix) === false ? $prefix : '') . $table;
+ } else {
+ throw new IcingaException('Table prefix handling for type "%s" is not supported', gettype($table));
+ }
+
+ return $table;
+ }
+
+ /**
+ * Remove the datasource's prefix from the given table name and return the remaining part
+ *
+ * @param array|string $table
+ *
+ * @return array|string
+ *
+ * @throws IcingaException In case $table is not of a supported type
+ */
+ protected function removeTablePrefix($table)
+ {
+ $prefix = $this->ds->getTablePrefix();
+ if (! $prefix) {
+ return $table;
+ }
+
+ if (is_array($table)) {
+ foreach ($table as & $tableName) {
+ if (strpos($tableName, $prefix) === 0) {
+ $tableName = str_replace($prefix, '', $tableName);
+ }
+ }
+ } elseif (is_string($table)) {
+ if (strpos($table, $prefix) === 0) {
+ $table = str_replace($prefix, '', $table);
+ }
+ } else {
+ throw new IcingaException('Table prefix handling for type "%s" is not supported', gettype($table));
+ }
+
+ return $table;
+ }
+
+ /**
+ * Return the given table with its alias being applied
+ *
+ * @param array|string $table
+ * @param string $virtualTable
+ *
+ * @return array|string
+ */
+ protected function applyTableAlias($table, $virtualTable = null)
+ {
+ if (! is_array($table)) {
+ $tableAliases = $this->getTableAliases();
+ if ($virtualTable !== null && isset($tableAliases[$virtualTable])) {
+ return array($tableAliases[$virtualTable] => $table);
+ }
+
+ if (isset($tableAliases[($nonPrefixedTable = $this->removeTablePrefix($table))])) {
+ return array($tableAliases[$nonPrefixedTable] => $table);
+ }
+ }
+
+ return $table;
+ }
+
+ /**
+ * Return the given table with its alias being cleared
+ *
+ * @param array|string $table
+ *
+ * @return string
+ *
+ * @throws IcingaException In case $table is not of a supported type
+ */
+ protected function clearTableAlias($table)
+ {
+ if (is_string($table)) {
+ return $table;
+ }
+
+ if (is_array($table)) {
+ return reset($table);
+ }
+
+ throw new IcingaException('Table alias handling for type "%s" is not supported', type($table));
+ }
+
+ /**
+ * Insert a table row with the given data
+ *
+ * Note that the base implementation does not perform any quoting on the $table argument.
+ * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value
+ * as third parameter $types to define a different type than string for a particular column.
+ *
+ * @param string $table
+ * @param array $bind
+ * @param array $types
+ *
+ * @return int The number of affected rows
+ */
+ public function insert($table, array $bind, array $types = array())
+ {
+ $realTable = $this->clearTableAlias($this->requireTable($table));
+
+ foreach ($types as $alias => $type) {
+ unset($types[$alias]);
+ $types[$this->requireStatementColumn($table, $alias)] = $type;
+ }
+
+ return $this->ds->insert($realTable, $this->requireStatementColumns($table, $bind), $types);
+ }
+
+ /**
+ * Update table rows with the given data, optionally limited by using a filter
+ *
+ * Note that the base implementation does not perform any quoting on the $table argument.
+ * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value
+ * as fourth parameter $types to define a different type than string for a particular column.
+ *
+ * @param string $table
+ * @param array $bind
+ * @param Filter $filter
+ * @param array $types
+ *
+ * @return int The number of affected rows
+ */
+ public function update($table, array $bind, Filter $filter = null, array $types = array())
+ {
+ $realTable = $this->clearTableAlias($this->requireTable($table));
+
+ if ($filter) {
+ $filter = $this->requireFilter($table, $filter);
+ }
+
+ foreach ($types as $alias => $type) {
+ unset($types[$alias]);
+ $types[$this->requireStatementColumn($table, $alias)] = $type;
+ }
+
+ return $this->ds->update($realTable, $this->requireStatementColumns($table, $bind), $filter, $types);
+ }
+
+ /**
+ * Delete table rows, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param Filter $filter
+ *
+ * @return int The number of affected rows
+ */
+ public function delete($table, Filter $filter = null)
+ {
+ $realTable = $this->clearTableAlias($this->requireTable($table));
+
+ if ($filter) {
+ $filter = $this->requireFilter($table, $filter);
+ }
+
+ return $this->ds->delete($realTable, $filter);
+ }
+
+ /**
+ * Return the statement columns being provided
+ *
+ * Calls $this->initializeStatementColumns() in case $this->statementColumns is null.
+ *
+ * @return array
+ */
+ public function getStatementColumns()
+ {
+ if ($this->statementColumns === null) {
+ $this->statementColumns = $this->initializeStatementColumns();
+ }
+
+ return $this->statementColumns;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the statement columns lazily
+ *
+ * @return array
+ */
+ protected function initializeStatementColumns()
+ {
+ return array();
+ }
+
+ /**
+ * Return an array to map table names to statement columns/aliases
+ *
+ * @return array
+ */
+ protected function getStatementAliasTableMap()
+ {
+ if ($this->statementAliasTableMap === null) {
+ $this->initializeStatementMaps();
+ }
+
+ return $this->statementAliasTableMap;
+ }
+
+ /**
+ * Return a flattened array to map statement columns to aliases
+ *
+ * @return array
+ */
+ protected function getStatementAliasColumnMap()
+ {
+ if ($this->statementAliasColumnMap === null) {
+ $this->initializeStatementMaps();
+ }
+
+ return $this->statementAliasColumnMap;
+ }
+
+ /**
+ * Return an array to map table names to statement columns
+ *
+ * @return array
+ */
+ protected function getStatementColumnTableMap()
+ {
+ if ($this->statementColumnTableMap === null) {
+ $this->initializeStatementMaps();
+ }
+
+ return $this->statementColumnTableMap;
+ }
+
+ /**
+ * Return a flattened array to map aliases to statement columns
+ *
+ * @return array
+ */
+ protected function getStatementColumnAliasMap()
+ {
+ if ($this->statementColumnAliasMap === null) {
+ $this->initializeStatementMaps();
+ }
+
+ return $this->statementColumnAliasMap;
+ }
+
+ /**
+ * Initialize $this->statementAliasTableMap and $this->statementAliasColumnMap
+ */
+ protected function initializeStatementMaps()
+ {
+ $this->statementAliasTableMap = array();
+ $this->statementAliasColumnMap = array();
+ $this->statementColumnTableMap = array();
+ $this->statementColumnAliasMap = array();
+ foreach ($this->getStatementColumns() as $table => $columns) {
+ foreach ($columns as $alias => $column) {
+ $key = is_string($alias) ? $alias : $column;
+ if (array_key_exists($key, $this->statementAliasTableMap)) {
+ if ($this->statementAliasTableMap[$key] !== null) {
+ $existingTable = $this->statementAliasTableMap[$key];
+ $existingColumn = $this->statementAliasColumnMap[$key];
+ $this->statementAliasTableMap[$existingTable . '.' . $key] = $existingTable;
+ $this->statementAliasColumnMap[$existingTable . '.' . $key] = $existingColumn;
+ $this->statementAliasTableMap[$key] = null;
+ $this->statementAliasColumnMap[$key] = null;
+ }
+
+ $this->statementAliasTableMap[$table . '.' . $key] = $table;
+ $this->statementAliasColumnMap[$table . '.' . $key] = $column;
+ } else {
+ $this->statementAliasTableMap[$key] = $table;
+ $this->statementAliasColumnMap[$key] = $column;
+ }
+
+ if (array_key_exists($column, $this->statementColumnTableMap)) {
+ if ($this->statementColumnTableMap[$column] !== null) {
+ $existingTable = $this->statementColumnTableMap[$column];
+ $existingAlias = $this->statementColumnAliasMap[$column];
+ $this->statementColumnTableMap[$existingTable . '.' . $column] = $existingTable;
+ $this->statementColumnAliasMap[$existingTable . '.' . $column] = $existingAlias;
+ $this->statementColumnTableMap[$column] = null;
+ $this->statementColumnAliasMap[$column] = null;
+ }
+
+ $this->statementColumnTableMap[$table . '.' . $column] = $table;
+ $this->statementColumnAliasMap[$table . '.' . $column] = $key;
+ } else {
+ $this->statementColumnTableMap[$column] = $table;
+ $this->statementColumnAliasMap[$column] = $key;
+ }
+ }
+ }
+ }
+
+ /**
+ * Return whether this repository is capable of converting values for the given table and optional column
+ *
+ * This does not check whether any conversion for the given table is available if $column is not given, as it
+ * may be possible that columns from another table where joined in which would otherwise not being converted.
+ *
+ * @param string $table
+ * @param string $column
+ *
+ * @return bool
+ */
+ public function providesValueConversion($table, $column = null)
+ {
+ if ($column !== null) {
+ if ($column instanceof Zend_Db_Expr) {
+ return false;
+ }
+
+ if ($this->validateQueryColumnAssociation($table, $column)) {
+ return parent::providesValueConversion($table, $column);
+ }
+
+ if (($tableName = $this->findTableName($column, $table))) {
+ return parent::providesValueConversion($tableName, $column);
+ }
+
+ return false;
+ }
+
+ $conversionRules = $this->getConversionRules();
+ return !empty($conversionRules);
+ }
+
+ /**
+ * Return the name of the conversion method for the given alias or column name and context
+ *
+ * If a query column or a filter column, which is part of a query filter, needs to be converted,
+ * you'll need to pass $query, otherwise the column is considered a statement column.
+ *
+ * @param string $table The datasource's table
+ * @param string $name The alias or column name for which to return a conversion method
+ * @param string $context The context of the conversion: persist or retrieve
+ * @param RepositoryQuery $query If given the column is considered a query column,
+ * statement column otherwise
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case a conversion rule is found but not any conversion method
+ */
+ protected function getConverter($table, $name, $context, RepositoryQuery $query = null)
+ {
+ if ($name instanceof Zend_Db_Expr) {
+ return;
+ }
+
+ if (! ($query !== null && $this->validateQueryColumnAssociation($table, $name))
+ && !($query === null && $this->validateStatementColumnAssociation($table, $name))
+ ) {
+ $table = $this->findTableName($name, $table);
+ if (! $table) {
+ if ($query !== null) {
+ // It may be an aliased Zend_Db_Expr
+ $desiredColumns = $query->getColumns();
+ if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) {
+ return;
+ }
+ }
+
+ throw new ProgrammingError('Column name validation seems to have failed. Did you require the column?');
+ }
+ }
+
+ return parent::getConverter($table, $name, $context, $query);
+ }
+
+ /**
+ * Validate that the requested table exists
+ *
+ * This will prepend the datasource's table prefix and will apply the table's alias, if any.
+ *
+ * @param string $table The table to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ * (unused by the base implementation)
+ *
+ * @return array|string
+ *
+ * @throws ProgrammingError In case the given table does not exist
+ */
+ public function requireTable($table, RepositoryQuery $query = null)
+ {
+ $virtualTable = null;
+ $statementColumns = $this->getStatementColumns();
+ if (! isset($statementColumns[$table])) {
+ $newTable = parent::requireTable($table);
+ if ($newTable !== $table) {
+ $virtualTable = $table;
+ }
+
+ $table = $newTable;
+ } else {
+ $virtualTables = $this->getVirtualTables();
+ if (isset($virtualTables[$table])) {
+ $virtualTable = $table;
+ $table = $virtualTables[$table];
+ }
+ }
+
+ return $this->prependTablePrefix($this->applyTableAlias($table, $virtualTable));
+ }
+
+ /**
+ * Return the alias for the given table or null if none has been defined
+ *
+ * @param string $table
+ *
+ * @return string|null
+ */
+ public function resolveTableAlias($table)
+ {
+ $tableAliases = $this->getTableAliases();
+ if (isset($tableAliases[$table])) {
+ return $tableAliases[$table];
+ }
+ }
+
+ /**
+ * Return the alias for the given query column name or null in case the query column name does not exist
+ *
+ * @param string $table
+ * @param string $column
+ *
+ * @return string|null
+ */
+ public function reassembleQueryColumnAlias($table, $column)
+ {
+ $alias = parent::reassembleQueryColumnAlias($table, $column);
+ if ($alias === null
+ && !$this->validateQueryColumnAssociation($table, $column)
+ && ($tableName = $this->findTableName($column, $table))
+ ) {
+ return parent::reassembleQueryColumnAlias($tableName, $column);
+ }
+
+ return $alias;
+ }
+
+ /**
+ * Validate that the given column is a valid query target and return it or the actual name if it's an alias
+ *
+ * Attempts to join the given column from a different table if its association to the given table cannot be
+ * verified.
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context,
+ * if not given no join will be attempted
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid query column
+ * @throws ProgrammingError In case the given column is not found in $table and cannot be joined in
+ */
+ public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
+ {
+ if ($name instanceof Zend_Db_Expr) {
+ return $name;
+ }
+
+ if ($query === null || $this->validateQueryColumnAssociation($table, $name)) {
+ return parent::requireQueryColumn($table, $name, $query);
+ }
+
+ $column = $this->joinColumn($name, $table, $query);
+ if ($column === null) {
+ if ($query !== null) {
+ // It may be an aliased Zend_Db_Expr
+ $desiredColumns = $query->getColumns();
+ if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) {
+ $column = $desiredColumns[$name];
+ }
+ }
+
+ if ($column === null) {
+ throw new ProgrammingError(
+ 'Unable to find a valid table for column "%s" to join into "%s"',
+ $name,
+ $table
+ );
+ }
+ }
+
+ return $column;
+ }
+
+ /**
+ * Validate that the given column is a valid filter target and return it or the actual name if it's an alias
+ *
+ * Attempts to join the given column from a different table if its association to the given table cannot be
+ * verified. In case of a PostgreSQL connection and if a COLLATE SQL-instruction is part of the resolved column,
+ * this applies LOWER() on the column and, if given, strtolower() on the filter's expression.
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context,
+ * if not given the column is considered being used for a statement filter
+ * @param FilterExpression $filter An optional filter to pass as context
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid filter column
+ * @throws ProgrammingError In case the given column is not found in $table and cannot be joined in
+ */
+ public function requireFilterColumn($table, $name, RepositoryQuery $query = null, FilterExpression $filter = null)
+ {
+ if ($name instanceof Zend_Db_Expr) {
+ return $name;
+ }
+
+ $joined = false;
+ if ($query === null) {
+ $column = $this->requireStatementColumn($table, $name);
+ } elseif ($this->validateQueryColumnAssociation($table, $name)) {
+ $column = parent::requireFilterColumn($table, $name, $query, $filter);
+ } else {
+ $column = $this->joinColumn($name, $table, $query);
+ if ($column === null) {
+ if ($query !== null) {
+ // It may be an aliased Zend_Db_Expr
+ $desiredColumns = $query->getColumns();
+ if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) {
+ $column = $desiredColumns[$name];
+ }
+ }
+
+ if ($column === null) {
+ throw new ProgrammingError(
+ 'Unable to find a valid table for column "%s" to join into "%s"',
+ $name,
+ $table
+ );
+ }
+ } else {
+ $joined = true;
+ }
+ }
+
+ if (! empty($this->caseInsensitiveColumns)) {
+ if ($joined) {
+ $table = $this->findTableName($name, $table);
+ }
+
+ if ($column === $name) {
+ if ($query === null) {
+ $name = $this->reassembleStatementColumnAlias($table, $name);
+ } else {
+ $name = $this->reassembleQueryColumnAlias($table, $name);
+ }
+ }
+
+ if (isset($this->caseInsensitiveColumns[$table][$name])) {
+ $column = 'LOWER(' . $column . ')';
+ if ($filter !== null) {
+ $expression = $filter->getExpression();
+ if (is_array($expression)) {
+ $filter->setExpression(array_map('strtolower', $expression));
+ } else {
+ $filter->setExpression(strtolower($expression));
+ }
+ }
+ }
+ }
+
+ return $column;
+ }
+
+ /**
+ * Return the statement column name for the given alias or null in case the alias does not exist
+ *
+ * @param string $table
+ * @param string $alias
+ *
+ * @return string|null
+ */
+ public function resolveStatementColumnAlias($table, $alias)
+ {
+ $statementAliasColumnMap = $this->getStatementAliasColumnMap();
+ if (isset($statementAliasColumnMap[$alias])) {
+ return $statementAliasColumnMap[$alias];
+ }
+
+ $prefixedAlias = $table . '.' . $alias;
+ if (isset($statementAliasColumnMap[$prefixedAlias])) {
+ return $statementAliasColumnMap[$prefixedAlias];
+ }
+ }
+
+ /**
+ * Return the alias for the given statement column name or null in case the statement column does not exist
+ *
+ * @param string $table
+ * @param string $column
+ *
+ * @return string|null
+ */
+ public function reassembleStatementColumnAlias($table, $column)
+ {
+ $statementColumnAliasMap = $this->getStatementColumnAliasMap();
+ if (isset($statementColumnAliasMap[$column])) {
+ return $statementColumnAliasMap[$column];
+ }
+
+ $prefixedColumn = $table . '.' . $column;
+ if (isset($statementColumnAliasMap[$prefixedColumn])) {
+ return $statementColumnAliasMap[$prefixedColumn];
+ }
+ }
+
+ /**
+ * Return whether the given alias or statement column name is available in the given table
+ *
+ * @param string $table
+ * @param string $alias
+ *
+ * @return bool
+ */
+ public function validateStatementColumnAssociation($table, $alias)
+ {
+ $statementAliasTableMap = $this->getStatementAliasTableMap();
+ if (isset($statementAliasTableMap[$alias])) {
+ return $statementAliasTableMap[$alias] === $table;
+ }
+
+ $prefixedAlias = $table . '.' . $alias;
+ if (isset($statementAliasTableMap[$prefixedAlias])) {
+ return true;
+ }
+
+ $statementColumnTableMap = $this->getStatementColumnTableMap();
+ if (isset($statementColumnTableMap[$alias])) {
+ return $statementColumnTableMap[$alias] === $table;
+ }
+
+ return isset($statementColumnTableMap[$prefixedAlias]);
+ }
+
+ /**
+ * Return whether the given column name or alias of the given table is a valid statement column
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The column name or alias to check
+ *
+ * @return bool
+ */
+ public function hasStatementColumn($table, $name)
+ {
+ if (($this->resolveStatementColumnAlias($table, $name) === null
+ && $this->reassembleStatementColumnAlias($table, $name) === null)
+ || !$this->validateStatementColumnAssociation($table, $name)
+ ) {
+ return parent::hasStatementColumn($table, $name);
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate that the given column is a valid statement column and return it or the actual name if it's an alias
+ *
+ * @param string $table The table for which to require the column
+ * @param string $name The name or alias of the column to validate
+ *
+ * @return string The given column's name
+ *
+ * @throws StatementException In case the given column is not a statement column
+ */
+ public function requireStatementColumn($table, $name)
+ {
+ if (($column = $this->resolveStatementColumnAlias($table, $name)) !== null) {
+ $alias = $name;
+ } elseif (($alias = $this->reassembleStatementColumnAlias($table, $name)) !== null) {
+ $column = $name;
+ } else {
+ return parent::requireStatementColumn($table, $name);
+ }
+
+ if (! $this->validateStatementColumnAssociation($table, $alias)) {
+ throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Join alias or column $name into $table using $query
+ *
+ * Attempts to find a valid table for the given alias or column name and a method labelled join<TableName>
+ * to process the actual join logic. If neither of those is found, null is returned.
+ * The method is called with the same parameters but in reversed order.
+ *
+ * @param string $name The alias or column name to join into $target
+ * @param string $target The table to join $name into
+ * @param RepositoryQuery $query The query to apply the JOIN-clause on
+ *
+ * @return string|null The resolved alias or $name, null if no join logic is found
+ */
+ public function joinColumn($name, $target, RepositoryQuery $query)
+ {
+ if (! ($tableName = $this->findTableName($name, $target))) {
+ return;
+ }
+
+ if (($column = $this->resolveQueryColumnAlias($tableName, $name)) === null) {
+ $column = $name;
+ }
+
+ if (($joinIdentifier = $this->resolveTableAlias($tableName)) === null) {
+ $joinIdentifier = $this->prependTablePrefix($tableName);
+ }
+ if ($query->getQuery()->hasJoinedTable($joinIdentifier)) {
+ return $column;
+ }
+
+ $joinMethod = 'join' . StringHelper::cname($tableName);
+ if (! method_exists($this, $joinMethod)) {
+ throw new ProgrammingError(
+ 'Unable to join table "%s" into "%s". Method "%s" not found',
+ $tableName,
+ $target,
+ $joinMethod
+ );
+ }
+
+ $this->$joinMethod($query, $target, $name);
+ return $column;
+ }
+
+ /**
+ * Return the table name for the given alias or column name
+ *
+ * @param string $column The alias or column name
+ * @param string $origin The base table of a SELECT query
+ *
+ * @return string|null null in case no table is found
+ */
+ protected function findTableName($column, $origin)
+ {
+ // First, try to produce an exact match since it's faster and cheaper
+ $aliasTableMap = $this->getAliasTableMap();
+ if (isset($aliasTableMap[$column])) {
+ $table = $aliasTableMap[$column];
+ } else {
+ $columnTableMap = $this->getColumnTableMap();
+ if (isset($columnTableMap[$column])) {
+ $table = $columnTableMap[$column];
+ }
+ }
+
+ // But only return it if it's a probable join...
+ $joinProbabilities = $this->getJoinProbabilities();
+ if (isset($joinProbabilities[$origin])) {
+ $probableJoins = $joinProbabilities[$origin];
+ }
+
+ // ...if probability can be determined
+ if (isset($table) && (empty($probableJoins) || in_array($table, $probableJoins, true))) {
+ return $table;
+ }
+
+ // Without a proper exact match, there is only one fast and cheap way to find a suitable table..
+ if (! empty($probableJoins)) {
+ foreach ($probableJoins as $table) {
+ if (isset($aliasTableMap[$table . '.' . $column])) {
+ return $table;
+ }
+ }
+ }
+
+ // Last chance to find a table. Though, this usually ends up with a QueryException..
+ foreach ($aliasTableMap as $prefixedAlias => $table) {
+ if (strpos($prefixedAlias, '.') !== false) {
+ list($_, $alias) = explode('.', $prefixedAlias, 2);
+ if ($alias === $column) {
+ return $table;
+ }
+ }
+ }
+ }
+}
diff --git a/library/Icinga/Repository/IniRepository.php b/library/Icinga/Repository/IniRepository.php
new file mode 100644
index 0000000..2519d03
--- /dev/null
+++ b/library/Icinga/Repository/IniRepository.php
@@ -0,0 +1,418 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Repository;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Extensible;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Updatable;
+use Icinga\Data\Reducible;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\StatementException;
+
+/**
+ * Abstract base class for concrete INI repository implementations
+ *
+ * Additionally provided features:
+ * <ul>
+ * <li>Insert, update and delete capabilities</li>
+ * <li>Triggers for inserts, updates and deletions</li>
+ * <li>Lazy initialization of table specific configs</li>
+ * </ul>
+ */
+abstract class IniRepository extends Repository implements Extensible, Updatable, Reducible
+{
+ /**
+ * The configuration files used as table specific datasources
+ *
+ * This must be initialized by concrete repository implementations, in the following format
+ * <code>
+ * array(
+ * 'table_name' => array(
+ * 'name' => 'name_of_the_ini_file_without_extension',
+ * 'keyColumn' => 'the_name_of_the_column_to_use_as_key_column',
+ * ['module' => 'the_name_of_the_module_if_any']
+ * )
+ * )
+ * </code>
+ *
+ * @var array
+ */
+ protected $configs;
+
+ /**
+ * The tables for which triggers are available when inserting, updating or deleting rows
+ *
+ * This may be initialized by concrete repository implementations and describes for which table names triggers
+ * are available. The repository attempts to find a method depending on the type of event and table for which
+ * to run the trigger. The name of such a method is expected to be declared using lowerCamelCase.
+ * (e.g. group_membership will be translated to onUpdateGroupMembership and groupmembership will be translated
+ * to onUpdateGroupmembership) The available events are onInsert, onUpdate and onDelete.
+ *
+ * @var array
+ */
+ protected $triggers;
+
+ /**
+ * Create a new INI repository object
+ *
+ * @param Config|null $ds The data source to use
+ *
+ * @throws ProgrammingError In case the given data source does not provide a valid key column
+ */
+ public function __construct(Config $ds = null)
+ {
+ parent::__construct($ds); // First! Due to init().
+
+ if ($ds !== null && !$ds->getConfigObject()->getKeyColumn()) {
+ throw new ProgrammingError('INI repositories require their data source to provide a valid key column');
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return Config
+ */
+ public function getDataSource($table = null)
+ {
+ if ($this->ds !== null) {
+ return parent::getDataSource($table);
+ }
+
+ $table = $table ?: $this->getBaseTable();
+ $configs = $this->getConfigs();
+ if (! isset($configs[$table])) {
+ throw new ProgrammingError('Config for table "%s" missing', $table);
+ } elseif (! $configs[$table] instanceof Config) {
+ $configs[$table] = $this->createConfig($configs[$table], $table);
+ }
+
+ if (! $configs[$table]->getConfigObject()->getKeyColumn()) {
+ throw new ProgrammingError(
+ 'INI repositories require their data source to provide a valid key column'
+ );
+ }
+
+ return $configs[$table];
+ }
+
+ /**
+ * Return the configuration files used as table specific datasources
+ *
+ * Calls $this->initializeConfigs() in case $this->configs is null.
+ *
+ * @return array
+ */
+ public function getConfigs()
+ {
+ if ($this->configs === null) {
+ $this->configs = $this->initializeConfigs();
+ }
+
+ return $this->configs;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the configs lazily
+ *
+ * @return array
+ */
+ protected function initializeConfigs()
+ {
+ return array();
+ }
+
+ /**
+ * Return the tables for which triggers are available when inserting, updating or deleting rows
+ *
+ * Calls $this->initializeTriggers() in case $this->triggers is null.
+ *
+ * @return array
+ */
+ public function getTriggers()
+ {
+ if ($this->triggers === null) {
+ $this->triggers = $this->initializeTriggers();
+ }
+
+ return $this->triggers;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the triggers lazily
+ *
+ * @return array
+ */
+ protected function initializeTriggers()
+ {
+ return array();
+ }
+
+ /**
+ * Run a trigger for the given table and row which is about to be inserted
+ *
+ * @param string $table
+ * @param ConfigObject $new
+ *
+ * @return ConfigObject
+ */
+ public function onInsert($table, ConfigObject $new)
+ {
+ $trigger = $this->getTrigger($table, 'onInsert');
+ if ($trigger !== null) {
+ $row = $this->$trigger($new);
+ if ($row !== null) {
+ $new = $row;
+ }
+ }
+
+ return $new;
+ }
+
+ /**
+ * Run a trigger for the given table and row which is about to be updated
+ *
+ * @param string $table
+ * @param ConfigObject $old
+ * @param ConfigObject $new
+ *
+ * @return ConfigObject
+ */
+ public function onUpdate($table, ConfigObject $old, ConfigObject $new)
+ {
+ $trigger = $this->getTrigger($table, 'onUpdate');
+ if ($trigger !== null) {
+ $row = $this->$trigger($old, $new);
+ if ($row !== null) {
+ $new = $row;
+ }
+ }
+
+ return $new;
+ }
+
+ /**
+ * Run a trigger for the given table and row which has been deleted
+ *
+ * @param string $table
+ * @param ConfigObject $old
+ */
+ public function onDelete($table, ConfigObject $old)
+ {
+ $trigger = $this->getTrigger($table, 'onDelete');
+ if ($trigger !== null) {
+ $this->$trigger($old);
+ }
+ }
+
+ /**
+ * Return the name of the trigger method for the given table and event-type
+ *
+ * @param string $table The table name for which to return a trigger method
+ * @param string $event The name of the event type
+ *
+ * @return ?string
+ */
+ protected function getTrigger($table, $event)
+ {
+ if (! in_array($table, $this->getTriggers())) {
+ return;
+ }
+
+ $identifier = join('', array_map('ucfirst', explode('_', $table)));
+ if (method_exists($this, $event . $identifier)) {
+ return $event . $identifier;
+ }
+ }
+
+ /**
+ * Insert the given data for the given target
+ *
+ * $data must provide a proper value for the data source's key column.
+ *
+ * @param string $target
+ * @param array $data
+ *
+ * @throws StatementException In case the operation has failed
+ */
+ public function insert($target, array $data)
+ {
+ $ds = $this->getDataSource($target);
+ $newData = $this->requireStatementColumns($target, $data);
+
+ $config = $this->onInsert($target, new ConfigObject($newData));
+ $section = $this->extractSectionName($config, $ds->getConfigObject()->getKeyColumn());
+
+ if ($ds->hasSection($section)) {
+ throw new StatementException(t('Cannot insert. Section "%s" does already exist'), $section);
+ }
+
+ $ds->setSection($section, $config);
+
+ try {
+ $ds->saveIni();
+ } catch (Exception $e) {
+ throw new StatementException(t('Failed to insert. An error occurred: %s'), $e->getMessage());
+ }
+ }
+
+ /**
+ * Update the target with the given data and optionally limit the affected entries by using a filter
+ *
+ * @param string $target
+ * @param array $data
+ * @param Filter $filter
+ *
+ * @throws StatementException In case the operation has failed
+ */
+ public function update($target, array $data, Filter $filter = null)
+ {
+ $ds = $this->getDataSource($target);
+ $newData = $this->requireStatementColumns($target, $data);
+
+ $keyColumn = $ds->getConfigObject()->getKeyColumn();
+ if ($filter === null && isset($newData[$keyColumn])) {
+ throw new StatementException(
+ t('Cannot update. Column "%s" holds a section\'s name which must be unique'),
+ $keyColumn
+ );
+ }
+
+ $query = $ds->select();
+ if ($filter !== null) {
+ $query->addFilter($this->requireFilter($target, $filter));
+ }
+
+ /** @var ConfigObject $config */
+ $newSection = null;
+ foreach ($query as $section => $config) {
+ if ($newSection !== null) {
+ throw new StatementException(
+ t('Cannot update. Column "%s" holds a section\'s name which must be unique'),
+ $keyColumn
+ );
+ }
+
+ $newConfig = clone $config;
+ foreach ($newData as $column => $value) {
+ if ($column === $keyColumn) {
+ if ($value !== $config->get($keyColumn)) {
+ $newSection = $value;
+ }
+ } else {
+ $newConfig->$column = $value;
+ }
+ }
+
+ // This is necessary as the query result set contains the key column.
+ unset($newConfig->$keyColumn);
+
+ if ($newSection) {
+ if ($ds->hasSection($newSection)) {
+ throw new StatementException(t('Cannot update. Section "%s" does already exist'), $newSection);
+ }
+
+ $ds->removeSection($section)->setSection(
+ $newSection,
+ $this->onUpdate($target, $config, $newConfig)
+ );
+ } else {
+ $ds->setSection(
+ $section,
+ $this->onUpdate($target, $config, $newConfig)
+ );
+ }
+ }
+
+ try {
+ $ds->saveIni();
+ } catch (Exception $e) {
+ throw new StatementException(t('Failed to update. An error occurred: %s'), $e->getMessage());
+ }
+ }
+
+ /**
+ * Delete entries in the given target, optionally limiting the affected entries by using a filter
+ *
+ * @param string $target
+ * @param Filter $filter
+ *
+ * @throws StatementException In case the operation has failed
+ */
+ public function delete($target, Filter $filter = null)
+ {
+ $ds = $this->getDataSource($target);
+
+ $query = $ds->select();
+ if ($filter !== null) {
+ $query->addFilter($this->requireFilter($target, $filter));
+ }
+
+ /** @var ConfigObject $config */
+ foreach ($query as $section => $config) {
+ $ds->removeSection($section);
+ $this->onDelete($target, $config);
+ }
+
+ try {
+ $ds->saveIni();
+ } catch (Exception $e) {
+ throw new StatementException(t('Failed to delete. An error occurred: %s'), $e->getMessage());
+ }
+ }
+
+ /**
+ * Create and return a Config for the given meta and table
+ *
+ * @param array $meta
+ * @param string $table
+ *
+ * @return Config
+ *
+ * @throws ProgrammingError In case the given meta is invalid
+ */
+ protected function createConfig(array $meta, $table)
+ {
+ if (! isset($meta['name'])) {
+ throw new ProgrammingError('Config file name missing for table "%s"', $table);
+ } elseif (! isset($meta['keyColumn'])) {
+ throw new ProgrammingError('Config key column name missing for table "%s"', $table);
+ }
+
+ if (isset($meta['module'])) {
+ $config = Config::module($meta['module'], $meta['name']);
+ } else {
+ $config = Config::app($meta['name']);
+ }
+
+ $config->getConfigObject()->setKeyColumn($meta['keyColumn']);
+ return $config;
+ }
+
+ /**
+ * Extract and return the section name off of the given $config
+ *
+ * @param array|ConfigObject $config
+ * @param string $keyColumn
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case no valid section name is available
+ */
+ protected function extractSectionName(&$config, $keyColumn)
+ {
+ if (! is_array($config) && !$config instanceof ConfigObject) {
+ throw new ProgrammingError('$config is neither an array nor a ConfigObject');
+ } elseif (! isset($config[$keyColumn])) {
+ throw new ProgrammingError('$config does not provide a value for key column "%s"', $keyColumn);
+ }
+
+ $section = $config[$keyColumn];
+ unset($config[$keyColumn]);
+ return $section;
+ }
+}
diff --git a/library/Icinga/Repository/LdapRepository.php b/library/Icinga/Repository/LdapRepository.php
new file mode 100644
index 0000000..af3cf00
--- /dev/null
+++ b/library/Icinga/Repository/LdapRepository.php
@@ -0,0 +1,71 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Repository;
+
+use Icinga\Protocol\Ldap\LdapConnection;
+
+/**
+ * Abstract base class for concrete LDAP repository implementations
+ *
+ * Additionally provided features:
+ * <ul>
+ * <li>Attribute name normalization</li>
+ * </ul>
+ */
+abstract class LdapRepository extends Repository
+{
+ /**
+ * The datasource being used
+ *
+ * @var LdapConnection
+ */
+ protected $ds;
+
+ /**
+ * Normed attribute names based on known LDAP environments
+ *
+ * @var array
+ */
+ protected $normedAttributes = array(
+ 'uid' => 'uid',
+ 'gid' => 'gid',
+ 'user' => 'user',
+ 'group' => 'group',
+ 'member' => 'member',
+ 'memberuid' => 'memberUid',
+ 'posixgroup' => 'posixGroup',
+ 'uniquemember' => 'uniqueMember',
+ 'groupofnames' => 'groupOfNames',
+ 'inetorgperson' => 'inetOrgPerson',
+ 'samaccountname' => 'sAMAccountName',
+ 'groupofuniquenames' => 'groupOfUniqueNames'
+ );
+
+ /**
+ * Create a new LDAP repository object
+ *
+ * @param LdapConnection $ds The data source to use
+ */
+ public function __construct(LdapConnection $ds)
+ {
+ parent::__construct($ds);
+ }
+
+ /**
+ * Return the given attribute name normed to known LDAP enviroments, if possible
+ *
+ * @param ?string $name
+ *
+ * @return string
+ */
+ protected function getNormedAttribute($name)
+ {
+ $loweredName = strtolower($name ?? '');
+ if (array_key_exists($loweredName, $this->normedAttributes)) {
+ return $this->normedAttributes[$loweredName];
+ }
+
+ return $name;
+ }
+}
diff --git a/library/Icinga/Repository/Repository.php b/library/Icinga/Repository/Repository.php
new file mode 100644
index 0000000..404f1f6
--- /dev/null
+++ b/library/Icinga/Repository/Repository.php
@@ -0,0 +1,1261 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Repository;
+
+use DateTime;
+use Icinga\Application\Logger;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Selectable;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\QueryException;
+use Icinga\Exception\StatementException;
+use Icinga\Util\ASN1;
+use Icinga\Util\StringHelper;
+use InvalidArgumentException;
+
+/**
+ * Abstract base class for concrete repository implementations
+ *
+ * To utilize this class and its features, the following is required:
+ * <ul>
+ * <li>Concrete implementations need to initialize Repository::$queryColumns</li>
+ * <li>The datasource passed to a repository must implement the Selectable interface</li>
+ * <li>The datasource must yield an instance of Queryable when its select() method is called</li>
+ * </ul>
+ */
+abstract class Repository implements Selectable
+{
+ /**
+ * The format to use when converting values of type date_time
+ */
+ const DATETIME_FORMAT = 'd/m/Y g:i A';
+
+ /**
+ * The name of this repository
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The datasource being used
+ *
+ * @var Selectable
+ */
+ protected $ds;
+
+ /**
+ * The base table name this repository is responsible for
+ *
+ * This will be automatically set to the first key of $queryColumns if not explicitly set.
+ *
+ * @var string
+ */
+ protected $baseTable;
+
+ /**
+ * The virtual tables being provided
+ *
+ * This may be initialized by concrete repository implementations with an array
+ * where a key is the name of a virtual table and its value the real table name.
+ *
+ * @var array
+ */
+ protected $virtualTables;
+
+ /**
+ * The query columns being provided
+ *
+ * This must be initialized by concrete repository implementations, in the following format
+ * <code>
+ * array(
+ * 'baseTable' => array(
+ * 'column1',
+ * 'alias1' => 'column2',
+ * 'alias2' => 'column3'
+ * )
+ * )
+ * </code>
+ *
+ * @var array
+ */
+ protected $queryColumns;
+
+ /**
+ * The columns (or aliases) which are not permitted to be queried
+ *
+ * Blacklisted query columns can still occur in a filter expression or sort rule.
+ *
+ * @var array An array of strings
+ */
+ protected $blacklistedQueryColumns;
+
+ /**
+ * Whether the blacklisted query columns are in the legacy format
+ *
+ * @var bool
+ */
+ protected $legacyBlacklistedQueryColumns;
+
+ /**
+ * The filter columns being provided
+ *
+ * This may be intialized by concrete repository implementations, in the following format
+ * <code>
+ * array(
+ * 'alias_or_column_name',
+ * 'label_to_show_in_the_filter_editor' => 'alias_or_column_name'
+ * )
+ * </code>
+ *
+ * @var array
+ */
+ protected $filterColumns;
+
+ /**
+ * Whether the provided filter columns are in the legacy format
+ *
+ * @var bool
+ */
+ protected $legacyFilterColumns;
+
+ /**
+ * The search columns (or aliases) being provided
+ *
+ * @var array An array of strings
+ */
+ protected $searchColumns;
+
+ /**
+ * Whether the provided search columns are in the legacy format
+ *
+ * @var bool
+ */
+ protected $legacySearchColumns;
+
+ /**
+ * The sort rules to be applied on a query
+ *
+ * This may be initialized by concrete repository implementations, in the following format
+ * <code>
+ * array(
+ * 'alias_or_column_name' => array(
+ * 'order' => 'asc'
+ * ),
+ * 'alias_or_column_name' => array(
+ * 'columns' => array(
+ * 'once_more_the_alias_or_column_name_as_in_the_parent_key',
+ * 'an_additional_alias_or_column_name_with_a_specific_direction asc'
+ * ),
+ * 'order' => 'desc'
+ * ),
+ * 'alias_or_column_name' => array(
+ * 'columns' => array('a_different_alias_or_column_name_designated_to_act_as_the_only_sort_column')
+ * // Ascendant sort by default
+ * )
+ * )
+ * </code>
+ * Note that it's mandatory to supply the alias name in case there is one.
+ *
+ * @var array
+ */
+ protected $sortRules;
+
+ /**
+ * Whether the provided sort rules are in the legacy format
+ *
+ * @var bool
+ */
+ protected $legacySortRules;
+
+ /**
+ * The value conversion rules to apply on a query or statement
+ *
+ * This may be initialized by concrete repository implementations and describes for which aliases or column
+ * names what type of conversion is available. For entries, where the key is the alias/column and the value
+ * is the type identifier, the repository attempts to find a conversion method for the alias/column first and,
+ * if none is found, then for the type. If an entry only provides a value, which is the alias/column, the
+ * repository only attempts to find a conversion method for the alias/column. The name of a conversion method
+ * is expected to be declared using lowerCamelCase. (e.g. user_name will be translated to persistUserName and
+ * groupname will be translated to retrieveGroupname)
+ *
+ * @var array
+ */
+ protected $conversionRules;
+
+ /**
+ * An array to map table names to aliases
+ *
+ * @var array
+ */
+ protected $aliasTableMap;
+
+ /**
+ * A flattened array to map query columns to aliases
+ *
+ * @var array
+ */
+ protected $aliasColumnMap;
+
+ /**
+ * An array to map table names to query columns
+ *
+ * @var array
+ */
+ protected $columnTableMap;
+
+ /**
+ * A flattened array to map aliases to query columns
+ *
+ * @var array
+ */
+ protected $columnAliasMap;
+
+ /**
+ * Create a new repository object
+ *
+ * @param Selectable|null $ds The datasource to use.
+ * Only pass null if you have overridden {@link getDataSource()}!
+ */
+ public function __construct(Selectable $ds = null)
+ {
+ $this->ds = $ds;
+ $this->aliasTableMap = array();
+ $this->aliasColumnMap = array();
+ $this->columnTableMap = array();
+ $this->columnAliasMap = array();
+
+ $this->init();
+ }
+
+ /**
+ * Initialize this repository
+ *
+ * Supposed to be overwritten by concrete repository implementations.
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Set this repository's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Return this repository's name
+ *
+ * In case no name has been explicitly set yet, the class name is returned.
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name ?: __CLASS__;
+ }
+
+ /**
+ * Return the datasource being used for the given table
+ *
+ * @param string $table
+ *
+ * @return Selectable
+ *
+ * @throws ProgrammingError In case no datasource is available
+ */
+ public function getDataSource($table = null)
+ {
+ if ($this->ds === null) {
+ throw new ProgrammingError(
+ 'No data source available. It is required to either pass it'
+ . ' at initialization time or by overriding this method.'
+ );
+ }
+
+ return $this->ds;
+ }
+
+ /**
+ * Return the base table name this repository is responsible for
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case no base table name has been set and
+ * $this->queryColumns does not provide one either
+ */
+ public function getBaseTable()
+ {
+ if ($this->baseTable === null) {
+ $queryColumns = $this->getQueryColumns();
+ reset($queryColumns);
+ $this->baseTable = key($queryColumns);
+ if (is_int($this->baseTable) || !is_array($queryColumns[$this->baseTable])) {
+ throw new ProgrammingError('"%s" is not a valid base table', $this->baseTable);
+ }
+ }
+
+ return $this->baseTable;
+ }
+
+ /**
+ * Return the virtual tables being provided
+ *
+ * Calls $this->initializeVirtualTables() in case $this->virtualTables is null.
+ *
+ * @return array
+ */
+ public function getVirtualTables()
+ {
+ if ($this->virtualTables === null) {
+ $this->virtualTables = $this->initializeVirtualTables();
+ }
+
+ return $this->virtualTables;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the virtual tables lazily
+ *
+ * @return array
+ */
+ protected function initializeVirtualTables()
+ {
+ return array();
+ }
+
+ /**
+ * Return the query columns being provided
+ *
+ * Calls $this->initializeQueryColumns() in case $this->queryColumns is null.
+ *
+ * @return array
+ */
+ public function getQueryColumns()
+ {
+ if ($this->queryColumns === null) {
+ $this->queryColumns = $this->initializeQueryColumns();
+ }
+
+ return $this->queryColumns;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the query columns lazily
+ *
+ * @return array
+ */
+ protected function initializeQueryColumns()
+ {
+ return array();
+ }
+
+ /**
+ * Return the columns (or aliases) which are not permitted to be queried
+ *
+ * Calls $this->initializeBlacklistedQueryColumns() in case $this->blacklistedQueryColumns is null.
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ public function getBlacklistedQueryColumns($table = null)
+ {
+ if ($this->blacklistedQueryColumns === null) {
+ $this->legacyBlacklistedQueryColumns = false;
+
+ $blacklistedQueryColumns = $this->initializeBlacklistedQueryColumns($table);
+ if (is_int(key($blacklistedQueryColumns))) {
+ $this->blacklistedQueryColumns[$table] = $blacklistedQueryColumns;
+ } else {
+ $this->blacklistedQueryColumns = $blacklistedQueryColumns;
+ }
+ } elseif ($this->legacyBlacklistedQueryColumns === null) {
+ $this->legacyBlacklistedQueryColumns = is_int(key($this->blacklistedQueryColumns));
+ }
+
+ if ($this->legacyBlacklistedQueryColumns) {
+ return $this->blacklistedQueryColumns;
+ } elseif (! isset($this->blacklistedQueryColumns[$table])) {
+ $this->blacklistedQueryColumns[$table] = $this->initializeBlacklistedQueryColumns($table);
+ }
+
+ return $this->blacklistedQueryColumns[$table];
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the
+ * blacklisted query columns lazily or dependent on a query's current base table
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ protected function initializeBlacklistedQueryColumns()
+ {
+ // $table is not part of the signature due to PHP strict standards
+ return array();
+ }
+
+ /**
+ * Return the filter columns being provided
+ *
+ * Calls $this->initializeFilterColumns() in case $this->filterColumns is null.
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ public function getFilterColumns($table = null)
+ {
+ if ($this->filterColumns === null) {
+ $this->legacyFilterColumns = false;
+
+ $filterColumns = $this->initializeFilterColumns($table);
+ $foundTables = array_intersect_key($this->getQueryColumns(), $filterColumns);
+ if (empty($foundTables)) {
+ $this->filterColumns[$table] = $filterColumns;
+ } else {
+ $this->filterColumns = $filterColumns;
+ }
+ } elseif ($this->legacyFilterColumns === null) {
+ $foundTables = array_intersect_key($this->getQueryColumns(), $this->filterColumns);
+ $this->legacyFilterColumns = empty($foundTables);
+ }
+
+ if ($this->legacyFilterColumns) {
+ return $this->filterColumns;
+ } elseif (! isset($this->filterColumns[$table])) {
+ $this->filterColumns[$table] = $this->initializeFilterColumns($table);
+ }
+
+ return $this->filterColumns[$table];
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize
+ * the filter columns lazily or dependent on a query's current base table
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ // $table is not part of the signature due to PHP strict standards
+ return array();
+ }
+
+ /**
+ * Return the search columns being provided
+ *
+ * Calls $this->initializeSearchColumns() in case $this->searchColumns is null.
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ public function getSearchColumns($table = null)
+ {
+ if ($this->searchColumns === null) {
+ $this->legacySearchColumns = false;
+
+ $searchColumns = $this->initializeSearchColumns($table);
+ if (is_int(key($searchColumns))) {
+ $this->searchColumns[$table] = $searchColumns;
+ } else {
+ $this->searchColumns = $searchColumns;
+ }
+ } elseif ($this->legacySearchColumns === null) {
+ $this->legacySearchColumns = is_int(key($this->searchColumns));
+ }
+
+ if ($this->legacySearchColumns) {
+ return $this->searchColumns;
+ } elseif (! isset($this->searchColumns[$table])) {
+ $this->searchColumns[$table] = $this->initializeSearchColumns($table);
+ }
+
+ return $this->searchColumns[$table];
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize
+ * the search columns lazily or dependent on a query's current base table
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ protected function initializeSearchColumns()
+ {
+ // $table is not part of the signature due to PHP strict standards
+ return array();
+ }
+
+ /**
+ * Return the sort rules to be applied on a query
+ *
+ * Calls $this->initializeSortRules() in case $this->sortRules is null.
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ public function getSortRules($table = null)
+ {
+ if ($this->sortRules === null) {
+ $this->legacySortRules = false;
+
+ $sortRules = $this->initializeSortRules($table);
+ $foundTables = array_intersect_key($this->getQueryColumns(), $sortRules);
+ if (empty($foundTables)) {
+ $this->sortRules[$table] = $sortRules;
+ } else {
+ $this->sortRules = $sortRules;
+ }
+ } elseif ($this->legacySortRules === null) {
+ $foundTables = array_intersect_key($this->getQueryColumns(), $this->sortRules);
+ $this->legacySortRules = empty($foundTables);
+ }
+
+ if ($this->legacySortRules) {
+ return $this->sortRules;
+ } elseif (! isset($this->sortRules[$table])) {
+ $this->sortRules[$table] = $this->initializeSortRules($table);
+ }
+
+ return $this->sortRules[$table];
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize
+ * the sort rules lazily or dependent on a query's current base table
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ protected function initializeSortRules()
+ {
+ // $table is not part of the signature due to PHP strict standards
+ return array();
+ }
+
+ /**
+ * Return the value conversion rules to apply on a query
+ *
+ * Calls $this->initializeConversionRules() in case $this->conversionRules is null.
+ *
+ * @return array
+ */
+ public function getConversionRules()
+ {
+ if ($this->conversionRules === null) {
+ $this->conversionRules = $this->initializeConversionRules();
+ }
+
+ return $this->conversionRules;
+ }
+
+ /**
+ * Overwrite this in your repository implementation in case you need to initialize the conversion rules lazily
+ *
+ * @return array
+ */
+ protected function initializeConversionRules()
+ {
+ return array();
+ }
+
+ /**
+ * Return an array to map table names to aliases
+ *
+ * @return array
+ */
+ protected function getAliasTableMap()
+ {
+ if (empty($this->aliasTableMap)) {
+ $this->initializeAliasMaps();
+ }
+
+ return $this->aliasTableMap;
+ }
+
+ /**
+ * Return a flattened array to map query columns to aliases
+ *
+ * @return array
+ */
+ protected function getAliasColumnMap()
+ {
+ if (empty($this->aliasColumnMap)) {
+ $this->initializeAliasMaps();
+ }
+
+ return $this->aliasColumnMap;
+ }
+
+ /**
+ * Return an array to map table names to query columns
+ *
+ * @return array
+ */
+ protected function getColumnTableMap()
+ {
+ if (empty($this->columnTableMap)) {
+ $this->initializeAliasMaps();
+ }
+
+ return $this->columnTableMap;
+ }
+
+ /**
+ * Return a flattened array to map aliases to query columns
+ *
+ * @return array
+ */
+ protected function getColumnAliasMap()
+ {
+ if (empty($this->columnAliasMap)) {
+ $this->initializeAliasMaps();
+ }
+
+ return $this->columnAliasMap;
+ }
+
+ /**
+ * Initialize $this->aliasTableMap and $this->aliasColumnMap
+ *
+ * @throws ProgrammingError In case $this->queryColumns does not provide any column information
+ */
+ protected function initializeAliasMaps()
+ {
+ $queryColumns = $this->getQueryColumns();
+ if (empty($queryColumns)) {
+ throw new ProgrammingError('Repositories are required to initialize $this->queryColumns first');
+ }
+
+ foreach ($queryColumns as $table => $columns) {
+ foreach ($columns as $alias => $column) {
+ if (! is_string($alias)) {
+ $key = $column;
+ } else {
+ $key = $alias;
+ $column = preg_replace('~\n\s*~', ' ', $column);
+ }
+
+ if (array_key_exists($key, $this->aliasTableMap)) {
+ if ($this->aliasTableMap[$key] !== null) {
+ $existingTable = $this->aliasTableMap[$key];
+ $existingColumn = $this->aliasColumnMap[$key];
+ $this->aliasTableMap[$existingTable . '.' . $key] = $existingTable;
+ $this->aliasColumnMap[$existingTable . '.' . $key] = $existingColumn;
+ $this->aliasTableMap[$key] = null;
+ $this->aliasColumnMap[$key] = null;
+ }
+
+ $this->aliasTableMap[$table . '.' . $key] = $table;
+ $this->aliasColumnMap[$table . '.' . $key] = $column;
+ } else {
+ $this->aliasTableMap[$key] = $table;
+ $this->aliasColumnMap[$key] = $column;
+ }
+
+ if (array_key_exists($column, $this->columnTableMap)) {
+ if ($this->columnTableMap[$column] !== null) {
+ $existingTable = $this->columnTableMap[$column];
+ $existingAlias = $this->columnAliasMap[$column];
+ $this->columnTableMap[$existingTable . '.' . $column] = $existingTable;
+ $this->columnAliasMap[$existingTable . '.' . $column] = $existingAlias;
+ $this->columnTableMap[$column] = null;
+ $this->columnAliasMap[$column] = null;
+ }
+
+ $this->columnTableMap[$table . '.' . $column] = $table;
+ $this->columnAliasMap[$table . '.' . $column] = $key;
+ } else {
+ $this->columnTableMap[$column] = $table;
+ $this->columnAliasMap[$column] = $key;
+ }
+ }
+ }
+ }
+
+ /**
+ * Return a new query for the given columns
+ *
+ * @param array $columns The desired columns, if null all columns will be queried
+ *
+ * @return RepositoryQuery
+ */
+ public function select(array $columns = null)
+ {
+ $query = new RepositoryQuery($this);
+ $query->from($this->getBaseTable(), $columns);
+ return $query;
+ }
+
+ /**
+ * Return whether this repository is capable of converting values for the given table and optional column
+ *
+ * @param string $table
+ * @param string $column
+ *
+ * @return bool
+ */
+ public function providesValueConversion($table, $column = null)
+ {
+ $conversionRules = $this->getConversionRules();
+ if (empty($conversionRules)) {
+ return false;
+ }
+
+ if (! isset($conversionRules[$table])) {
+ return false;
+ } elseif ($column === null) {
+ return true;
+ }
+
+ $alias = $this->reassembleQueryColumnAlias($table, $column) ?: $column;
+ return array_key_exists($alias, $conversionRules[$table]) || in_array($alias, $conversionRules[$table]);
+ }
+
+ /**
+ * Convert a value supposed to be transmitted to the data source
+ *
+ * @param string $table The table where to persist the value
+ * @param string $name The alias or column name
+ * @param mixed $value The value to convert
+ * @param RepositoryQuery $query An optional query to pass as context
+ * (Directly passed through to $this->getConverter)
+ *
+ * @return mixed If conversion was possible, the converted value,
+ * otherwise the unchanged value
+ */
+ public function persistColumn($table, $name, $value, RepositoryQuery $query = null)
+ {
+ $converter = $this->getConverter($table, $name, 'persist', $query);
+ if ($converter !== null) {
+ $value = $this->$converter($value, $name, $table, $query);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Convert a value which was fetched from the data source
+ *
+ * @param string $table The table the value has been fetched from
+ * @param string $name The alias or column name
+ * @param mixed $value The value to convert
+ * @param RepositoryQuery $query An optional query to pass as context
+ * (Directly passed through to $this->getConverter)
+ *
+ * @return mixed If conversion was possible, the converted value,
+ * otherwise the unchanged value
+ */
+ public function retrieveColumn($table, $name, $value, RepositoryQuery $query = null)
+ {
+ $converter = $this->getConverter($table, $name, 'retrieve', $query);
+ if ($converter !== null) {
+ $value = $this->$converter($value, $name, $table, $query);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Return the name of the conversion method for the given alias or column name and context
+ *
+ * @param string $table The datasource's table
+ * @param string $name The alias or column name for which to return a conversion method
+ * @param string $context The context of the conversion: persist or retrieve
+ * @param RepositoryQuery $query An optional query to pass as context
+ * (unused by the base implementation)
+ *
+ * @return ?string
+ *
+ * @throws ProgrammingError In case a conversion rule is found but not any conversion method
+ */
+ protected function getConverter($table, $name, $context, RepositoryQuery $query = null)
+ {
+ $conversionRules = $this->getConversionRules();
+ if (! isset($conversionRules[$table])) {
+ return null;
+ }
+
+ $tableRules = $conversionRules[$table];
+ if (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) {
+ $alias = $name;
+ }
+
+ // Check for a conversion method for the alias/column first
+ if (array_key_exists($alias, $tableRules) || in_array($alias, $tableRules)) {
+ $methodName = $context . join('', array_map('ucfirst', explode('_', $alias)));
+ if (method_exists($this, $methodName)) {
+ return $methodName;
+ }
+ }
+
+ // The conversion method for the type is just a fallback, but it is required to exist if defined
+ if (isset($tableRules[$alias])) {
+ $identifier = join('', array_map('ucfirst', explode('_', $tableRules[$alias])));
+ if (! method_exists($this, $context . $identifier)) {
+ // Do not throw an error in case at least one conversion method exists
+ if (! method_exists($this, ($context === 'persist' ? 'retrieve' : 'persist') . $identifier)) {
+ throw new ProgrammingError(
+ 'Cannot find any conversion method for type "%s"'
+ . '. Add a proper conversion method or remove the type definition',
+ $tableRules[$alias]
+ );
+ }
+
+ Logger::debug(
+ 'Conversion method "%s" for type definition "%s" does not exist in repository "%s".',
+ $context . $identifier,
+ $tableRules[$alias],
+ $this->getName()
+ );
+ } else {
+ return $context . $identifier;
+ }
+ }
+ }
+
+ /**
+ * Convert a timestamp or DateTime object to a string formatted using static::DATETIME_FORMAT
+ *
+ * @param mixed $value
+ *
+ * @return string
+ */
+ protected function persistDateTime($value)
+ {
+ if (is_numeric($value)) {
+ $value = date(static::DATETIME_FORMAT, $value);
+ } elseif ($value instanceof DateTime) {
+ $value = date(static::DATETIME_FORMAT, $value->getTimestamp()); // Using date here, to ignore any timezone
+ } elseif ($value !== null) {
+ throw new ProgrammingError(
+ 'Cannot persist value "%s" as type date_time. It\'s not a timestamp or DateTime object',
+ $value
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Convert a string formatted using static::DATETIME_FORMAT to a unix timestamp
+ *
+ * @param string $value
+ *
+ * @return int
+ */
+ protected function retrieveDateTime($value)
+ {
+ if (is_numeric($value)) {
+ $value = (int) $value;
+ } elseif (is_string($value)) {
+ $dateTime = DateTime::createFromFormat(static::DATETIME_FORMAT, $value);
+ if ($dateTime === false) {
+ Logger::debug(
+ 'Unable to parse string "%s" as type date_time with format "%s" in repository "%s"',
+ $value,
+ static::DATETIME_FORMAT,
+ $this->getName()
+ );
+ $value = null;
+ } else {
+ $value = $dateTime->getTimestamp();
+ }
+ } elseif ($value !== null) {
+ throw new ProgrammingError(
+ 'Cannot retrieve value "%s" as type date_time. It\'s not a integer or (numeric) string',
+ $value
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Convert the given array to an comma separated string
+ *
+ * @param array|string $value
+ *
+ * @return string
+ */
+ protected function persistCommaSeparatedString($value)
+ {
+ if (is_array($value)) {
+ $value = join(',', array_map('trim', $value));
+ } elseif ($value !== null && !is_string($value)) {
+ throw new ProgrammingError('Cannot persist value "%s" as comma separated string', $value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Convert the given comma separated string to an array
+ *
+ * @param string $value
+ *
+ * @return array
+ */
+ protected function retrieveCommaSeparatedString($value)
+ {
+ if ($value && is_string($value)) {
+ $value = StringHelper::trimSplit($value);
+ } elseif ($value !== null) {
+ throw new ProgrammingError('Cannot retrieve value "%s" as array. It\'s not a string', $value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Parse the given value based on the ASN.1 standard (GeneralizedTime) and return its timestamp representation
+ *
+ * @param string|null $value
+ *
+ * @return ?int
+ *
+ * @see https://tools.ietf.org/html/rfc4517#section-3.3.13
+ */
+ protected function retrieveGeneralizedTime($value)
+ {
+ if ($value === null) {
+ return $value;
+ }
+
+ try {
+ return ASN1::parseGeneralizedTime($value)->getTimestamp();
+ } catch (InvalidArgumentException $e) {
+ Logger::debug(sprintf('Repository "%s": %s', $this->getName(), $e->getMessage()));
+ }
+ }
+
+ /**
+ * Validate that the requested table exists and resolve it's real name if necessary
+ *
+ * @param string $table The table to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ * (unused by the base implementation)
+ *
+ * @return string The table's name, may differ from the given one
+ *
+ * @throws ProgrammingError In case the given table does not exist
+ */
+ public function requireTable($table, RepositoryQuery $query = null)
+ {
+ $queryColumns = $this->getQueryColumns();
+ if (! isset($queryColumns[$table])) {
+ throw new ProgrammingError('Table "%s" not found', $table);
+ }
+
+ $virtualTables = $this->getVirtualTables();
+ if (isset($virtualTables[$table])) {
+ $table = $virtualTables[$table];
+ }
+
+ return $table;
+ }
+
+ /**
+ * Recurse the given filter, require each column for the given table and convert all values
+ *
+ * @param string $table The table being filtered
+ * @param Filter $filter The filter to recurse
+ * @param RepositoryQuery $query An optional query to pass as context
+ * (Directly passed through to $this->requireFilterColumn)
+ * @param bool $clone Whether to clone $filter first
+ *
+ * @return Filter The udpated filter
+ */
+ public function requireFilter($table, Filter $filter, RepositoryQuery $query = null, $clone = true)
+ {
+ if ($clone) {
+ $filter = clone $filter;
+ }
+
+ if ($filter->isExpression()) {
+ $column = $filter->getColumn();
+ $filter->setColumn($this->requireFilterColumn($table, $column, $query, $filter));
+ $filter->setExpression($this->persistColumn($table, $column, $filter->getExpression(), $query));
+ } elseif ($filter->isChain()) {
+ foreach ($filter->filters() as $chainOrExpression) {
+ $this->requireFilter($table, $chainOrExpression, $query, false);
+ }
+ }
+
+ return $filter;
+ }
+
+ /**
+ * Return this repository's query columns of the given table mapped to their respective aliases
+ *
+ * @param string $table
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case $table does not exist
+ */
+ public function requireAllQueryColumns($table)
+ {
+ $queryColumns = $this->getQueryColumns();
+ if (! array_key_exists($table, $queryColumns)) {
+ throw new ProgrammingError('Table name "%s" not found', $table);
+ }
+
+ $blacklist = $this->getBlacklistedQueryColumns($table);
+ $columns = array();
+ foreach ($queryColumns[$table] as $alias => $column) {
+ $name = is_string($alias) ? $alias : $column;
+ if (! in_array($name, $blacklist)) {
+ $columns[$alias] = $this->resolveQueryColumnAlias($table, $name);
+ }
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Return the query column name for the given alias or null in case the alias does not exist
+ *
+ * @param string $table
+ * @param string $alias
+ *
+ * @return string|null
+ */
+ public function resolveQueryColumnAlias($table, $alias)
+ {
+ $aliasColumnMap = $this->getAliasColumnMap();
+ if (isset($aliasColumnMap[$alias])) {
+ return $aliasColumnMap[$alias];
+ }
+
+ $prefixedAlias = $table . '.' . $alias;
+ if (isset($aliasColumnMap[$prefixedAlias])) {
+ return $aliasColumnMap[$prefixedAlias];
+ }
+ }
+
+ /**
+ * Return the alias for the given query column name or null in case the query column name does not exist
+ *
+ * @param string $table
+ * @param string $column
+ *
+ * @return string|null
+ */
+ public function reassembleQueryColumnAlias($table, $column)
+ {
+ $columnAliasMap = $this->getColumnAliasMap();
+ if (isset($columnAliasMap[$column])) {
+ return $columnAliasMap[$column];
+ }
+
+ $prefixedColumn = $table . '.' . $column;
+ if (isset($columnAliasMap[$prefixedColumn])) {
+ return $columnAliasMap[$prefixedColumn];
+ }
+ }
+
+ /**
+ * Return whether the given alias or query column name is available in the given table
+ *
+ * @param string $table
+ * @param string $alias
+ *
+ * @return bool
+ */
+ public function validateQueryColumnAssociation($table, $alias)
+ {
+ $aliasTableMap = $this->getAliasTableMap();
+ if (isset($aliasTableMap[$alias])) {
+ return $aliasTableMap[$alias] === $table;
+ }
+
+ $prefixedAlias = $table . '.' . $alias;
+ if (isset($aliasTableMap[$prefixedAlias])) {
+ return true;
+ }
+
+ $columnTableMap = $this->getColumnTableMap();
+ if (isset($columnTableMap[$alias])) {
+ return $columnTableMap[$alias] === $table;
+ }
+
+ return isset($columnTableMap[$prefixedAlias]);
+ }
+
+ /**
+ * Return whether the given column name or alias is a valid query column
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The column name or alias to check
+ *
+ * @return bool
+ */
+ public function hasQueryColumn($table, $name)
+ {
+ if ($this->resolveQueryColumnAlias($table, $name) !== null) {
+ $alias = $name;
+ } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) {
+ return false;
+ }
+
+ return !in_array($alias, $this->getBlacklistedQueryColumns($table))
+ && $this->validateQueryColumnAssociation($table, $name);
+ }
+
+ /**
+ * Validate that the given column is a valid query target and return it or the actual name if it's an alias
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context (unused by the base implementation)
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid query column
+ */
+ public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
+ {
+ if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) {
+ $alias = $name;
+ } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) {
+ $column = $name;
+ } else {
+ throw new QueryException(t('Query column "%s" not found'), $name);
+ }
+
+ if (in_array($alias, $this->getBlacklistedQueryColumns($table))) {
+ throw new QueryException(t('Column "%s" cannot be queried'), $name);
+ }
+
+ if (! $this->validateQueryColumnAssociation($table, $alias)) {
+ throw new QueryException(t('Query column "%s" not found in table "%s"'), $name, $table);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Return whether the given column name or alias is a valid filter column
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The column name or alias to check
+ *
+ * @return bool
+ */
+ public function hasFilterColumn($table, $name)
+ {
+ return ($this->resolveQueryColumnAlias($table, $name) !== null
+ || $this->reassembleQueryColumnAlias($table, $name) !== null)
+ && $this->validateQueryColumnAssociation($table, $name);
+ }
+
+ /**
+ * Validate that the given column is a valid filter target and return it or the actual name if it's an alias
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context (unused by the base implementation)
+ * @param FilterExpression $filter An optional filter to pass as context (unused by the base implementation)
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid filter column
+ */
+ public function requireFilterColumn($table, $name, RepositoryQuery $query = null, FilterExpression $filter = null)
+ {
+ if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) {
+ $alias = $name;
+ } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) {
+ $column = $name;
+ } else {
+ throw new QueryException(t('Filter column "%s" not found'), $name);
+ }
+
+ if (! $this->validateQueryColumnAssociation($table, $alias)) {
+ throw new QueryException(t('Filter column "%s" not found in table "%s"'), $name, $table);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Return whether the given column name or alias of the given table is a valid statement column
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The column name or alias to check
+ *
+ * @return bool
+ */
+ public function hasStatementColumn($table, $name)
+ {
+ return $this->hasQueryColumn($table, $name);
+ }
+
+ /**
+ * Validate that the given column is a valid statement column and return it or the actual name if it's an alias
+ *
+ * @param string $table The table for which to require the column
+ * @param string $name The name or alias of the column to validate
+ *
+ * @return string The given column's name
+ *
+ * @throws StatementException In case the given column is not a statement column
+ */
+ public function requireStatementColumn($table, $name)
+ {
+ if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) {
+ $alias = $name;
+ } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) {
+ $column = $name;
+ } else {
+ throw new StatementException('Statement column "%s" not found', $name);
+ }
+
+ if (in_array($alias, $this->getBlacklistedQueryColumns($table))) {
+ throw new StatementException('Column "%s" cannot be referenced in a statement', $name);
+ }
+
+ if (! $this->validateQueryColumnAssociation($table, $alias)) {
+ throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Resolve the given aliases or column names of the given table supposed to be persisted and convert their values
+ *
+ * @param string $table
+ * @param array $data
+ *
+ * @return array
+ */
+ public function requireStatementColumns($table, array $data)
+ {
+ $resolved = array();
+ foreach ($data as $alias => $value) {
+ $resolved[$this->requireStatementColumn($table, $alias)] = $this->persistColumn($table, $alias, $value);
+ }
+
+ return $resolved;
+ }
+}
diff --git a/library/Icinga/Repository/RepositoryQuery.php b/library/Icinga/Repository/RepositoryQuery.php
new file mode 100644
index 0000000..84f7c6e
--- /dev/null
+++ b/library/Icinga/Repository/RepositoryQuery.php
@@ -0,0 +1,797 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Repository;
+
+use Iterator;
+use IteratorAggregate;
+use Traversable;
+use Icinga\Application\Benchmark;
+use Icinga\Application\Logger;
+use Icinga\Data\QueryInterface;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\FilterColumns;
+use Icinga\Data\SortRules;
+use Icinga\Exception\QueryException;
+
+/**
+ * Query class supposed to mediate between a repository and its datasource's query
+ */
+class RepositoryQuery implements QueryInterface, SortRules, FilterColumns, Iterator
+{
+ /**
+ * The repository being used
+ *
+ * @var Repository
+ */
+ protected $repository;
+
+ /**
+ * The real query being used
+ *
+ * @var QueryInterface
+ */
+ protected $query;
+
+ /**
+ * The current target to be queried
+ *
+ * @var mixed
+ */
+ protected $target;
+
+ /**
+ * The real query's iterator
+ *
+ * @var Iterator
+ */
+ protected $iterator;
+
+ /**
+ * This query's custom aliases
+ *
+ * @var array
+ */
+ protected $customAliases;
+
+ /**
+ * Create a new repository query
+ *
+ * @param Repository $repository The repository to use
+ */
+ public function __construct(Repository $repository)
+ {
+ $this->repository = $repository;
+ }
+
+ /**
+ * Clone all state relevant properties of this query
+ */
+ public function __clone()
+ {
+ if ($this->query !== null) {
+ $this->query = clone $this->query;
+ }
+ if ($this->iterator !== null) {
+ $this->iterator = clone $this->iterator;
+ }
+ }
+
+ /**
+ * Return a string representation of this query
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return (string) $this->query;
+ }
+
+ /**
+ * Return the real query being used
+ *
+ * @return QueryInterface
+ */
+ public function getQuery()
+ {
+ return $this->query;
+ }
+
+ /**
+ * Set where to fetch which columns
+ *
+ * This notifies the repository about each desired query column.
+ *
+ * @param mixed $target The target from which to fetch the columns
+ * @param array $columns If null or an empty array, all columns will be fetched
+ *
+ * @return $this
+ */
+ public function from($target, array $columns = null)
+ {
+ $this->query = $this->repository->getDataSource($target)->select();
+ $this->query->from($this->repository->requireTable($target, $this));
+ $this->query->columns($this->prepareQueryColumns($target, $columns));
+ $this->target = $target;
+ return $this;
+ }
+
+ /**
+ * Return the columns to fetch
+ *
+ * @return array
+ */
+ public function getColumns()
+ {
+ return $this->query->getColumns();
+ }
+
+ /**
+ * Set which columns to fetch
+ *
+ * This notifies the repository about each desired query column.
+ *
+ * @param array $columns If null or an empty array, all columns will be fetched
+ *
+ * @return $this
+ */
+ public function columns(array $columns)
+ {
+ $this->query->columns($this->prepareQueryColumns($this->target, $columns));
+ return $this;
+ }
+
+ /**
+ * Resolve the given columns supposed to be fetched
+ *
+ * This notifies the repository about each desired query column.
+ *
+ * @param mixed $target The target where to look for each column
+ * @param array $desiredColumns Pass null or an empty array to require all query columns
+ *
+ * @return array The desired columns indexed by their respective alias
+ */
+ protected function prepareQueryColumns($target, array $desiredColumns = null)
+ {
+ $this->customAliases = array();
+ if (empty($desiredColumns)) {
+ $columns = $this->repository->requireAllQueryColumns($target);
+ } else {
+ $columns = array();
+ foreach ($desiredColumns as $customAlias => $columnAlias) {
+ $resolvedColumn = $this->repository->requireQueryColumn($target, $columnAlias, $this);
+ if ($resolvedColumn !== $columnAlias) {
+ if (is_string($customAlias)) {
+ $columns[$customAlias] = $resolvedColumn;
+ $this->customAliases[$customAlias] = $columnAlias;
+ } else {
+ $columns[$columnAlias] = $resolvedColumn;
+ }
+ } elseif (is_string($customAlias)) {
+ $columns[$customAlias] = $columnAlias;
+ $this->customAliases[$customAlias] = $columnAlias;
+ } else {
+ $columns[] = $columnAlias;
+ }
+ }
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Return the native column alias for the given custom alias
+ *
+ * If no custom alias is found with the given name, it is returned unchanged.
+ *
+ * @param string $customAlias
+ *
+ * @return string
+ */
+ protected function getNativeAlias($customAlias)
+ {
+ if (isset($this->customAliases[$customAlias])) {
+ return $this->customAliases[$customAlias];
+ }
+
+ return $customAlias;
+ }
+
+ /**
+ * Return this query's available filter columns with their optional label as key
+ *
+ * @return array
+ */
+ public function getFilterColumns()
+ {
+ return $this->repository->getFilterColumns($this->target);
+ }
+
+ /**
+ * Return this query's available search columns
+ *
+ * @return array
+ */
+ public function getSearchColumns()
+ {
+ return $this->repository->getSearchColumns($this->target);
+ }
+
+ /**
+ * Filter this query using the given column and value
+ *
+ * This notifies the repository about the required filter column.
+ *
+ * @param string $column
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function where($column, $value = null)
+ {
+ $this->addFilter(Filter::where($column, $value));
+ return $this;
+ }
+
+ /**
+ * Add an additional filter expression to this query
+ *
+ * This notifies the repository about each required filter column.
+ *
+ * @param Filter $filter
+ *
+ * @return $this
+ */
+ public function applyFilter(Filter $filter)
+ {
+ return $this->addFilter($filter);
+ }
+
+ /**
+ * Set a filter for this query
+ *
+ * This notifies the repository about each required filter column.
+ *
+ * @param Filter $filter
+ *
+ * @return $this
+ */
+ public function setFilter(Filter $filter)
+ {
+ $this->query->setFilter($this->repository->requireFilter($this->target, $filter, $this));
+ return $this;
+ }
+
+ /**
+ * Add an additional filter expression to this query
+ *
+ * This notifies the repository about each required filter column.
+ *
+ * @param Filter $filter
+ *
+ * @return $this
+ */
+ public function addFilter(Filter $filter)
+ {
+ $this->query->addFilter($this->repository->requireFilter($this->target, $filter, $this));
+ return $this;
+ }
+
+ /**
+ * Return the current filter
+ *
+ * @return Filter
+ */
+ public function getFilter()
+ {
+ return $this->query->getFilter();
+ }
+
+ /**
+ * Return the sort rules being applied on this query
+ *
+ * @return array
+ */
+ public function getSortRules()
+ {
+ return $this->repository->getSortRules($this->target);
+ }
+
+ /**
+ * Add a sort rule for this query
+ *
+ * If called without a specific column, the repository's defaul sort rules will be applied.
+ * This notifies the repository about each column being required as filter column.
+ *
+ * @param string $field The name of the column by which to sort the query's result
+ * @param string $direction The direction to use when sorting (asc or desc, default is asc)
+ * @param bool $ignoreDefault Whether to ignore any default sort rules if $field is given
+ *
+ * @return $this
+ */
+ public function order($field = null, $direction = null, $ignoreDefault = false)
+ {
+ $sortRules = $this->getSortRules();
+ if ($field === null) {
+ // Use first available sort rule as default
+ if (empty($sortRules)) {
+ // Return early in case of no sort defaults and no given $field
+ return $this;
+ }
+
+ $sortColumns = reset($sortRules);
+ if (! array_key_exists('columns', $sortColumns)) {
+ $sortColumns['columns'] = array(key($sortRules));
+ }
+ if ($direction !== null || !array_key_exists('order', $sortColumns)) {
+ $sortColumns['order'] = $direction ?: static::SORT_ASC;
+ }
+ } else {
+ $alias = $this->repository->reassembleQueryColumnAlias($this->target, $field) ?: $field;
+ if (! $ignoreDefault && array_key_exists($alias, $sortRules)) {
+ $sortColumns = $sortRules[$alias];
+ if (! array_key_exists('columns', $sortColumns)) {
+ $sortColumns['columns'] = array($alias);
+ }
+ if ($direction !== null || !array_key_exists('order', $sortColumns)) {
+ $sortColumns['order'] = $direction ?: static::SORT_ASC;
+ }
+ } else {
+ $sortColumns = array(
+ 'columns' => array($alias),
+ 'order' => $direction
+ );
+ }
+ }
+
+ $baseDirection = isset($sortColumns['order']) && strtoupper($sortColumns['order']) === static::SORT_DESC
+ ? static::SORT_DESC
+ : static::SORT_ASC;
+
+ foreach ($sortColumns['columns'] as $column) {
+ list($column, $specificDirection) = $this->splitOrder($column);
+
+ if ($this->hasLimit() && $this->repository->providesValueConversion($this->target, $column)) {
+ Logger::debug(
+ 'Cannot order by column "%s" in repository "%s". The query is'
+ . ' limited and applies value conversion rules on the column',
+ $column,
+ $this->repository->getName()
+ );
+ continue;
+ }
+
+ try {
+ $this->query->order(
+ $this->repository->requireFilterColumn($this->target, $column, $this),
+ $specificDirection ?: $baseDirection
+ // I would have liked the following solution, but hey, a coder should be allowed to produce crap...
+ // $specificDirection && (! $direction || $column !== $field) ? $specificDirection : $baseDirection
+ );
+ } catch (QueryException $_) {
+ Logger::info('Cannot order by column "%s" in repository "%s"', $column, $this->repository->getName());
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Extract and return the name and direction of the given sort column definition
+ *
+ * @param string $field
+ *
+ * @return array An array of two items: $columnName, $direction
+ */
+ protected function splitOrder($field)
+ {
+ $columnAndDirection = explode(' ', $field, 2);
+ if (count($columnAndDirection) === 1) {
+ $column = $field;
+ $direction = null;
+ } else {
+ $column = $columnAndDirection[0];
+ $direction = strtoupper($columnAndDirection[1]) === static::SORT_DESC
+ ? static::SORT_DESC
+ : static::SORT_ASC;
+ }
+
+ return array($column, $direction);
+ }
+
+ /**
+ * Return whether any sort rules were applied to this query
+ *
+ * @return bool
+ */
+ public function hasOrder()
+ {
+ return $this->query->hasOrder();
+ }
+
+ /**
+ * Return the sort rules applied to this query
+ *
+ * @return array
+ */
+ public function getOrder()
+ {
+ return $this->query->getOrder();
+ }
+
+ /**
+ * Set whether this query should peek ahead for more results
+ *
+ * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will
+ * be removed from the result set. Note that this only applies when fetching multiple results of limited queries.
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function peekAhead($state = true)
+ {
+ $this->query->peekAhead($state);
+ return $this;
+ }
+
+ /**
+ * Return whether this query did not yield all available results
+ *
+ * @return bool
+ */
+ public function hasMore()
+ {
+ return $this->query->hasMore();
+ }
+
+ /**
+ * Return whether this query will or has yielded any result
+ *
+ * @return bool
+ */
+ public function hasResult()
+ {
+ return $this->query->hasResult();
+ }
+
+ /**
+ * Limit this query's results
+ *
+ * @param int $count When to stop returning results
+ * @param int $offset When to start returning results
+ *
+ * @return $this
+ */
+ public function limit($count = null, $offset = null)
+ {
+ $this->query->limit($count, $offset);
+ return $this;
+ }
+
+ /**
+ * Return whether this query does not return all available entries from its result
+ *
+ * @return bool
+ */
+ public function hasLimit()
+ {
+ return $this->query->hasLimit();
+ }
+
+ /**
+ * Return the limit when to stop returning results
+ *
+ * @return int
+ */
+ public function getLimit()
+ {
+ return $this->query->getLimit();
+ }
+
+ /**
+ * Return whether this query does not start returning results at the very first entry
+ *
+ * @return bool
+ */
+ public function hasOffset()
+ {
+ return $this->query->hasOffset();
+ }
+
+ /**
+ * Return the offset when to start returning results
+ *
+ * @return int
+ */
+ public function getOffset()
+ {
+ return $this->query->getOffset();
+ }
+
+ /**
+ * Fetch and return the first column of this query's first row
+ *
+ * @return mixed|false False in case of no result
+ */
+ public function fetchOne()
+ {
+ if (! $this->hasOrder()) {
+ $this->order();
+ }
+
+ $result = $this->query->fetchOne();
+ if ($result !== false && $this->repository->providesValueConversion($this->target)) {
+ $columns = $this->getColumns();
+ $column = isset($columns[0]) ? $columns[0] : $this->getNativeAlias(key($columns));
+ return $this->repository->retrieveColumn($this->target, $column, $result, $this);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch and return the first row of this query's result
+ *
+ * @return object|false False in case of no result
+ */
+ public function fetchRow()
+ {
+ if (! $this->hasOrder()) {
+ $this->order();
+ }
+
+ $result = $this->query->fetchRow();
+ if ($result !== false && $this->repository->providesValueConversion($this->target)) {
+ foreach ($this->getColumns() as $alias => $column) {
+ if (! is_string($alias)) {
+ $alias = $column;
+ }
+
+ $result->$alias = $this->repository->retrieveColumn(
+ $this->target,
+ $this->getNativeAlias($alias),
+ $result->$alias,
+ $this
+ );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch and return the first column of all rows of the result set as an array
+ *
+ * @return array
+ */
+ public function fetchColumn()
+ {
+ if (! $this->hasOrder()) {
+ $this->order();
+ }
+
+ $results = $this->query->fetchColumn();
+ if (! empty($results) && $this->repository->providesValueConversion($this->target)) {
+ $columns = $this->getColumns();
+ $aliases = array_keys($columns);
+ $column = is_int($aliases[0]) ? $columns[0] : $this->getNativeAlias($aliases[0]);
+ if ($this->repository->providesValueConversion($this->target, $column)) {
+ foreach ($results as & $value) {
+ $value = $this->repository->retrieveColumn($this->target, $column, $value, $this);
+ }
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Fetch and return all rows of this query's result set as an array of key-value pairs
+ *
+ * The first column is the key, the second column is the value.
+ *
+ * @return array
+ */
+ public function fetchPairs()
+ {
+ if (! $this->hasOrder()) {
+ $this->order();
+ }
+
+ $results = $this->query->fetchPairs();
+ if (! empty($results) && $this->repository->providesValueConversion($this->target)) {
+ $columns = $this->getColumns();
+ $aliases = array_keys($columns);
+ $colOne = $aliases[0] !== 0 ? $this->getNativeAlias($aliases[0]) : $columns[0];
+ $colTwo = count($aliases) < 2 ? $colOne : (
+ $aliases[1] !== 1 ? $this->getNativeAlias($aliases[1]) : $columns[1]
+ );
+
+ if ($this->repository->providesValueConversion($this->target, $colOne)
+ || $this->repository->providesValueConversion($this->target, $colTwo)
+ ) {
+ $newResults = array();
+ foreach ($results as $colOneValue => $colTwoValue) {
+ $colOneValue = $this->repository->retrieveColumn($this->target, $colOne, $colOneValue, $this);
+ $newResults[$colOneValue] = $this->repository->retrieveColumn(
+ $this->target,
+ $colTwo,
+ $colTwoValue,
+ $this
+ );
+ }
+
+ $results = $newResults;
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Fetch and return all results of this query
+ *
+ * @return array
+ */
+ public function fetchAll()
+ {
+ if (! $this->hasOrder()) {
+ $this->order();
+ }
+
+ $results = $this->query->fetchAll();
+ if (! empty($results) && $this->repository->providesValueConversion($this->target)) {
+ $updateOrder = false;
+ $columns = $this->getColumns();
+ $flippedColumns = array_flip($columns);
+ foreach ($results as $row) {
+ foreach ($columns as $alias => $column) {
+ if (! is_string($alias)) {
+ $alias = $column;
+ }
+
+ $row->$alias = $this->repository->retrieveColumn(
+ $this->target,
+ $this->getNativeAlias($alias),
+ $row->$alias,
+ $this
+ );
+ }
+
+ foreach (($this->getOrder() ?: array()) as $rule) {
+ $nativeAlias = $this->getNativeAlias($rule[0]);
+ if (! array_key_exists($rule[0], $flippedColumns) && property_exists($row, $rule[0])) {
+ if ($this->repository->providesValueConversion($this->target, $nativeAlias)) {
+ $updateOrder = true;
+ $row->{$rule[0]} = $this->repository->retrieveColumn(
+ $this->target,
+ $nativeAlias,
+ $row->{$rule[0]},
+ $this
+ );
+ }
+ } elseif (array_key_exists($rule[0], $flippedColumns)) {
+ if ($this->repository->providesValueConversion($this->target, $nativeAlias)) {
+ $updateOrder = true;
+ }
+ }
+ }
+ }
+
+ if ($updateOrder) {
+ uasort($results, array($this->query, 'compare'));
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Count all results of this query
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return $this->query->count();
+ }
+
+ /**
+ * Return the current position of this query's iterator
+ *
+ * @return int
+ */
+ public function getIteratorPosition()
+ {
+ return $this->query->getIteratorPosition();
+ }
+
+ /**
+ * Start or rewind the iteration
+ */
+ public function rewind(): void
+ {
+ if ($this->iterator === null) {
+ if (! $this->hasOrder()) {
+ $this->order();
+ }
+
+ if ($this->query instanceof Traversable) {
+ $iterator = $this->query;
+ } else {
+ $iterator = $this->repository->getDataSource($this->target)->query($this->query);
+ }
+
+ if ($iterator instanceof IteratorAggregate) {
+ $this->iterator = $iterator->getIterator();
+ } else {
+ $this->iterator = $iterator;
+ }
+ }
+
+ $this->iterator->rewind();
+ Benchmark::measure('Query result iteration started');
+ }
+
+ /**
+ * Fetch and return the current row of this query's result
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ $row = $this->iterator->current();
+ if ($this->repository->providesValueConversion($this->target)) {
+ foreach ($this->getColumns() as $alias => $column) {
+ if (! is_string($alias)) {
+ $alias = $column;
+ }
+
+ $row->$alias = $this->repository->retrieveColumn(
+ $this->target,
+ $this->getNativeAlias($alias),
+ $row->$alias,
+ $this
+ );
+ }
+ }
+
+ return $row;
+ }
+
+ /**
+ * Return whether the current row of this query's result is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ if (! $this->iterator->valid()) {
+ Benchmark::measure('Query result iteration finished');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the key for the current row of this query's result
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->iterator->key();
+ }
+
+ /**
+ * Advance to the next row of this query's result
+ */
+ public function next(): void
+ {
+ $this->iterator->next();
+ }
+}
diff --git a/library/Icinga/Security/SecurityException.php b/library/Icinga/Security/SecurityException.php
new file mode 100644
index 0000000..861dcf1
--- /dev/null
+++ b/library/Icinga/Security/SecurityException.php
@@ -0,0 +1,13 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Security;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Exception thrown when a caller does not have the permissions required to access a resource
+ */
+class SecurityException extends IcingaException
+{
+}
diff --git a/library/Icinga/Test/BaseTestCase.php b/library/Icinga/Test/BaseTestCase.php
new file mode 100644
index 0000000..1283013
--- /dev/null
+++ b/library/Icinga/Test/BaseTestCase.php
@@ -0,0 +1,313 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Test {
+
+ use Exception;
+ use Icinga\Util\Csp;
+ use Icinga\Web\Request;
+ use Icinga\Web\Response;
+ use Icinga\Web\Session;
+ use ipl\I18n\NoopTranslator;
+ use ipl\I18n\StaticTranslator;
+ use RuntimeException;
+ use Mockery;
+ use Icinga\Application\Icinga;
+ use Icinga\Data\ConfigObject;
+ use Icinga\Data\ResourceFactory;
+ use Icinga\Data\Db\DbConnection;
+ use Tests\Icinga\Lib\FakeSession;
+
+ /**
+ * Class BaseTestCase
+ */
+ abstract class BaseTestCase extends Mockery\Adapter\Phpunit\MockeryTestCase implements DbTest
+ {
+ /**
+ * Path to application/
+ *
+ * @var string
+ * @deprecated Use Icinga::app()->getApplicationDir() instead
+ */
+ public static $appDir;
+
+ /**
+ * Path to library/Icinga
+ *
+ * @var string
+ * @deprecated Use Icinga::app()->getLibraryDir('Icinga') instead
+ */
+ public static $libDir;
+
+ /**
+ * Path to etc/
+ *
+ * @var string
+ * @deprecated Use Icinga::app()->getBaseDir('etc') instead
+ */
+ public static $etcDir;
+
+ /**
+ * Path to test/php/
+ *
+ * @var string
+ * @deprecated Use Icinga::app()->getBaseDir('test/php') instead
+ */
+ public static $testDir;
+
+ /**
+ * Path to share/icinga2-web
+ *
+ * @var string
+ * @deprecated Unused
+ */
+ public static $shareDir;
+
+ /**
+ * Path to modules/
+ *
+ * @var string
+ * @deprecated Use Icinga::app()->getModuleManager()->getModuleDirs() instead
+ */
+ public static $moduleDir;
+
+ /**
+ * Resource configuration for different database types
+ *
+ * @var array
+ */
+ protected static $dbConfiguration = array(
+ 'mysql' => array(
+ 'type' => 'db',
+ 'db' => 'mysql',
+ 'host' => '127.0.0.1',
+ 'port' => 3306,
+ 'dbname' => 'icinga_unittest',
+ 'username' => 'icinga_unittest',
+ 'password' => 'icinga_unittest'
+ ),
+ 'pgsql' => array(
+ 'type' => 'db',
+ 'db' => 'pgsql',
+ 'host' => '127.0.0.1',
+ 'port' => 5432,
+ 'dbname' => 'icinga_unittest',
+ 'username' => 'icinga_unittest',
+ 'password' => 'icinga_unittest'
+ ),
+ );
+
+ /** @var Request */
+ private $requestMock;
+
+ /** @var Response */
+ private $responseMock;
+
+ /**
+ * Setup MVC bootstrapping and ensure that the Icinga-Mock gets reinitialized
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->setupRequestMock();
+ $this->setupResponseMock();
+ Session::create(new FakeSession());
+ Csp::createNonce();
+
+ StaticTranslator::$instance = new NoopTranslator();
+ }
+
+ private function setupRequestMock()
+ {
+ $this->requestMock = Mockery::mock('Icinga\Web\Request')->shouldDeferMissing();
+ $this->requestMock->shouldReceive('getPathInfo')->andReturn('')->byDefault()
+ ->shouldReceive('getBaseUrl')->andReturn('/')->byDefault()
+ ->shouldReceive('getQuery')->andReturn(array())->byDefault()
+ ->shouldReceive('getParam')->with(Mockery::type('string'), Mockery::type('string'))
+ ->andReturnUsing(function ($name, $default) {
+ return $default;
+ })->byDefault();
+
+ Icinga::app()->setRequest($this->requestMock);
+ }
+
+ private function setupResponseMock()
+ {
+ $this->responseMock = Mockery::mock('Icinga\Web\Response')->shouldDeferMissing();
+ Icinga::app()->setResponse($this->responseMock);
+ }
+
+ /**
+ * Return the currently active request mock object
+ *
+ * @return Request
+ */
+ public function getRequestMock()
+ {
+ return $this->requestMock;
+ }
+
+ /**
+ * Return the currently active response mock object
+ *
+ * @return Response
+ */
+ public function getResponseMock()
+ {
+ return $this->responseMock;
+ }
+
+ /**
+ * Create Config for database configuration
+ *
+ * @param string $name
+ *
+ * @return ConfigObject
+ * @throws RuntimeException
+ */
+ protected function createDbConfigFor($name)
+ {
+ if (array_key_exists($name, self::$dbConfiguration)) {
+ $config = new ConfigObject(self::$dbConfiguration[$name]);
+
+ $host = getenv(sprintf('ICINGAWEB_TEST_%s_HOST', strtoupper($name)));
+ if ($host) {
+ $config['host'] = $host;
+ }
+
+ $port = getenv(sprintf('ICINGAWEB_TEST_%s_PORT', strtoupper($name)));
+ if ($port) {
+ $config['port'] = $port;
+ }
+
+ return $config;
+ }
+
+ throw new RuntimeException('Configuration for database type not available: ' . $name);
+ }
+
+ /**
+ * Creates an array of Icinga\Data\Db\DbConnection
+ *
+ * @param string $name
+ *
+ * @return array
+ */
+ protected function createDbConnectionFor($name)
+ {
+ try {
+ $conn = ResourceFactory::createResource($this->createDbConfigFor($name));
+ } catch (Exception $e) {
+ $conn = $e->getMessage();
+ }
+
+ return array(
+ array($conn)
+ );
+ }
+
+ /**
+ * PHPUnit provider for mysql
+ *
+ * @return DbConnection
+ */
+ public function mysqlDb()
+ {
+ return $this->createDbConnectionFor('mysql');
+ }
+
+ /**
+ * PHPUnit provider for pgsql
+ *
+ * @return DbConnection
+ */
+ public function pgsqlDb()
+ {
+ return $this->createDbConnectionFor('pgsql');
+ }
+
+ /**
+ * PHPUnit provider for oracle
+ *
+ * @return DbConnection
+ */
+ public function oracleDb()
+ {
+ return $this->createDbConnectionFor('oracle');
+ }
+
+ /**
+ * Executes sql file by using the database connection
+ *
+ * @param DbConnection $resource
+ * @param string $filename
+ *
+ * @throws RuntimeException
+ */
+ public function loadSql(DbConnection $resource, $filename)
+ {
+ if (!is_file($filename)) {
+ throw new RuntimeException(
+ 'Sql file not found: ' . $filename . ' (test=' . $this->getName() . ')'
+ );
+ }
+
+ $sqlData = file_get_contents($filename);
+
+ if (!$sqlData) {
+ throw new RuntimeException(
+ 'Sql file is empty: ' . $filename . ' (test=' . $this->getName() . ')'
+ );
+ }
+
+ $resource->getDbAdapter()->exec($sqlData);
+ }
+
+ /**
+ * Setup provider for testcase
+ *
+ * @param string|DbConnection|null $resource
+ */
+ public function setupDbProvider($resource)
+ {
+ if (!$resource instanceof DbConnection) {
+ if (is_string($resource)) {
+ $this->markTestSkipped('Could not initialize provider: ' . $resource);
+ } else {
+ $this->markTestSkipped('Could not initialize provider');
+ }
+ return;
+ }
+
+ $adapter = $resource->getDbAdapter();
+
+ try {
+ $adapter->getConnection();
+ } catch (Exception $e) {
+ $this->markTestSkipped('Could not connect to provider: '. $e->getMessage());
+ }
+
+ $tables = $adapter->listTables();
+ foreach ($tables as $table) {
+ $adapter->exec('DROP TABLE ' . $table . ';');
+ }
+ }
+
+ /**
+ * Add assertMatchesRegularExpression() method for phpunit >= 8.0 < 9.0 for compatibility with PHP 7.2.
+ *
+ * @TODO Remove once PHP 7.2 support is not needed for testing anymore.
+ */
+ public static function assertMatchesRegularExpression(
+ string $pattern,
+ string $string,
+ string $message = ''
+ ): void {
+ if (method_exists(parent::class, 'assertMatchesRegularExpression')) {
+ parent::assertMatchesRegularExpression($pattern, $string, $message);
+ } else {
+ static::assertRegExp($pattern, $string, $message);
+ }
+ }
+ }
+}
diff --git a/library/Icinga/Test/ClassLoader.php b/library/Icinga/Test/ClassLoader.php
new file mode 100644
index 0000000..af90a7e
--- /dev/null
+++ b/library/Icinga/Test/ClassLoader.php
@@ -0,0 +1,113 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Test;
+
+/**
+ * PSR-4 class loader
+ */
+class ClassLoader
+{
+ /**
+ * Namespace separator
+ */
+ const NAMESPACE_SEPARATOR = '\\';
+
+ /**
+ * Namespaces
+ *
+ * @var array
+ */
+ private $namespaces = array();
+
+ /**
+ * Register a base directory for a namespace prefix
+ *
+ * @param string $namespace
+ * @param string $directory
+ *
+ * @return $this
+ */
+ public function registerNamespace($namespace, $directory)
+ {
+ $this->namespaces[$namespace] = $directory;
+
+ return $this;
+ }
+
+ /**
+ * Test whether a namespace exists
+ *
+ * @param string $namespace
+ *
+ * @return bool
+ */
+ public function hasNamespace($namespace)
+ {
+ return array_key_exists($namespace, $this->namespaces);
+ }
+
+ /**
+ * Get the source file of the given class or interface
+ *
+ * @param string $class Name of the class or interface
+ *
+ * @return string|null
+ */
+ public function getSourceFile($class)
+ {
+ foreach ($this->namespaces as $namespace => $dir) {
+ if ($class === strstr($class, $namespace)) {
+ $classPath = str_replace(
+ self::NAMESPACE_SEPARATOR,
+ DIRECTORY_SEPARATOR,
+ substr($class, strlen($namespace))
+ ) . '.php';
+ if (file_exists($file = $dir . $classPath)) {
+ return $file;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Load the given class or interface
+ *
+ * @param string $class Name of the class or interface
+ *
+ * @return bool Whether the class or interface has been loaded
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->getSourceFile($class)) {
+ require $file;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Register {@link loadClass()} as an autoloader
+ */
+ public function register()
+ {
+ spl_autoload_register(array($this, 'loadClass'));
+ }
+
+ /**
+ * Unregister {@link loadClass()} as an autoloader
+ */
+ public function unregister()
+ {
+ spl_autoload_unregister(array($this, 'loadClass'));
+ }
+
+ /**
+ * Unregister this as an autoloader
+ */
+ public function __destruct()
+ {
+ $this->unregister();
+ }
+}
diff --git a/library/Icinga/Test/DbTest.php b/library/Icinga/Test/DbTest.php
new file mode 100644
index 0000000..d1b1ff0
--- /dev/null
+++ b/library/Icinga/Test/DbTest.php
@@ -0,0 +1,47 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Test;
+
+use Icinga\Data\Db\DbConnection;
+
+interface DbTest
+{
+ /**
+ * PHPUnit provider for mysql
+ *
+ * @return DbConnection
+ */
+ public function mysqlDb();
+
+ /**
+ * PHPUnit provider for pgsql
+ *
+ * @return DbConnection
+ */
+ public function pgsqlDb();
+
+ /**
+ * PHPUnit provider for oracle
+ *
+ * @return DbConnection
+ */
+ public function oracleDb();
+
+ /**
+ * Executes sql file on PDO object
+ *
+ * @param DbConnection $resource
+ * @param string $filename
+ *
+ * @return boolean Operational success flag
+ */
+ public function loadSql(DbConnection $resource, $filename);
+
+ /**
+ * Setup provider for testcase
+ *
+ * @param string|DbConnection|null $resource
+ */
+ public function setupDbProvider($resource);
+}
diff --git a/library/Icinga/User.php b/library/Icinga/User.php
new file mode 100644
index 0000000..8610dd0
--- /dev/null
+++ b/library/Icinga/User.php
@@ -0,0 +1,649 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga;
+
+use DateTimeZone;
+use Icinga\Authentication\AdmissionLoader;
+use InvalidArgumentException;
+use Icinga\Application\Config;
+use Icinga\Authentication\Role;
+use Icinga\Exception\ProgrammingError;
+use Icinga\User\Preferences;
+use Icinga\Web\Navigation\Navigation;
+
+/**
+ * This class represents an authorized user
+ *
+ * You can retrieve authorization information (@TODO: Not implemented yet) or user information
+ */
+class User
+{
+ /**
+ * Firstname
+ *
+ * @var string
+ */
+ protected $firstname;
+
+ /**
+ * Lastname
+ *
+ * @var string
+ */
+ protected $lastname;
+
+ /**
+ * Users email address
+ *
+ * @var string
+ */
+ protected $email;
+
+ /**
+ * {@link username} without {@link domain}
+ *
+ * @var string
+ */
+ protected $localUsername;
+
+ /**
+ * Domain
+ *
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * More information about this user
+ *
+ * @var array
+ */
+ protected $additionalInformation = array();
+
+ /**
+ * Information if the user is externally authenticated
+ *
+ * Keys:
+ *
+ * 0: origin username
+ * 1: origin field name
+ *
+ * @var array
+ */
+ protected $externalUserInformation = array();
+
+ /**
+ * Whether restrictions should not apply to this user
+ *
+ * @var bool
+ */
+ protected $unrestricted = false;
+
+ /**
+ * Set of permissions
+ *
+ * @var array
+ */
+ protected $permissions = array();
+
+ /**
+ * Set of restrictions
+ *
+ * @var array
+ */
+ protected $restrictions = array();
+
+ /**
+ * Groups for this user
+ *
+ * @var array
+ */
+ protected $groups = array();
+
+ /**
+ * Roles of this user
+ *
+ * @var Role[]
+ */
+ protected $roles = array();
+
+ /**
+ * Preferences object
+ *
+ * @var Preferences
+ */
+ protected $preferences;
+
+ /**
+ * Whether the user is authenticated using a HTTP authentication mechanism
+ *
+ * @var bool
+ */
+ protected $isHttpUser = false;
+
+ /**
+ * Creates a user object given the provided information
+ *
+ * @param string $username
+ * @param string $firstname
+ * @param string $lastname
+ * @param string $email
+ */
+ public function __construct($username, $firstname = null, $lastname = null, $email = null)
+ {
+ $this->setUsername($username);
+
+ if ($firstname !== null) {
+ $this->setFirstname($firstname);
+ }
+
+ if ($lastname !== null) {
+ $this->setLastname($lastname);
+ }
+
+ if ($email !== null) {
+ $this->setEmail($email);
+ }
+ }
+
+ /**
+ * Setter for preferences
+ *
+ * @param Preferences $preferences
+ *
+ * @return $this
+ */
+ public function setPreferences(Preferences $preferences)
+ {
+ $this->preferences = $preferences;
+ return $this;
+ }
+
+ /**
+ * Getter for preferences
+ *
+ * @return Preferences
+ */
+ public function getPreferences()
+ {
+ if ($this->preferences === null) {
+ $this->preferences = new Preferences();
+ }
+
+ return $this->preferences;
+ }
+
+ /**
+ * Return all groups this user belongs to
+ *
+ * @return array
+ */
+ public function getGroups()
+ {
+ return $this->groups;
+ }
+
+ /**
+ * Set the groups this user belongs to
+ *
+ * @param array $groups
+ *
+ * @return $this
+ */
+ public function setGroups(array $groups)
+ {
+ $this->groups = $groups;
+ return $this;
+ }
+
+ /**
+ * Return true if the user is a member of this group
+ *
+ * @param string $group
+ *
+ * @return boolean
+ */
+ public function isMemberOf($group)
+ {
+ return in_array($group, $this->groups);
+ }
+
+ /**
+ * Get whether restrictions should not apply to this user
+ *
+ * @return bool
+ */
+ public function isUnrestricted()
+ {
+ return $this->unrestricted;
+ }
+
+ /**
+ * Set whether restrictions should not apply to this user
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setIsUnrestricted($state)
+ {
+ $this->unrestricted = (bool) $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the user's permissions
+ *
+ * @return array
+ */
+ public function getPermissions()
+ {
+ return $this->permissions;
+ }
+
+ /**
+ * Set the user's permissions
+ *
+ * @param array $permissions
+ *
+ * @return $this
+ */
+ public function setPermissions(array $permissions)
+ {
+ if (! empty($permissions)) {
+ natcasesort($permissions);
+ $this->permissions = array_combine($permissions, $permissions);
+ }
+ return $this;
+ }
+
+ /**
+ * Return restriction information for this user
+ *
+ * @param string $name
+ *
+ * @return array
+ */
+ public function getRestrictions($name)
+ {
+ if (array_key_exists($name, $this->restrictions)) {
+ return $this->restrictions[$name];
+ }
+
+ return array();
+ }
+
+ /**
+ * Set the user's restrictions
+ *
+ * @param string[] $restrictions
+ *
+ * @return $this
+ */
+ public function setRestrictions(array $restrictions)
+ {
+ $this->restrictions = $restrictions;
+ return $this;
+ }
+
+ /**
+ * Get the roles of the user
+ *
+ * @return Role[]
+ */
+ public function getRoles()
+ {
+ return $this->roles;
+ }
+
+ /**
+ * Set the roles of the user
+ *
+ * @param Role[] $roles
+ *
+ * @return $this
+ */
+ public function setRoles(array $roles)
+ {
+ $this->roles = $roles;
+ return $this;
+ }
+
+ /**
+ * Getter for username
+ *
+ * @return string
+ */
+ public function getUsername()
+ {
+ return $this->domain === null ? $this->localUsername : $this->localUsername . '@' . $this->domain;
+ }
+
+ /**
+ * Setter for username
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setUsername($name)
+ {
+ $parts = explode('\\', $name, 2);
+ if (count($parts) === 2) {
+ list($this->domain, $this->localUsername) = $parts;
+ } else {
+ $parts = explode('@', $name, 2);
+ if (count($parts) === 2) {
+ list($this->localUsername, $this->domain) = $parts;
+ } else {
+ $this->localUsername = $name;
+ $this->domain = null;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Getter for firstname
+ *
+ * @return string
+ */
+ public function getFirstname()
+ {
+ return $this->firstname;
+ }
+
+ /**
+ * Setter for firstname
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setFirstname($name)
+ {
+ $this->firstname = $name;
+ return $this;
+ }
+
+ /**
+ * Getter for lastname
+ *
+ * @return string
+ */
+ public function getLastname()
+ {
+ return $this->lastname;
+ }
+
+ /**
+ * Setter for lastname
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setLastname($name)
+ {
+ $this->lastname = $name;
+ return $this;
+ }
+
+ /**
+ * Getter for email
+ *
+ * @return string
+ */
+ public function getEmail()
+ {
+ return $this->email;
+ }
+
+ /**
+ * Setter for mail
+ *
+ * @param string $mail
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException When an invalid mail is provided
+ */
+ public function setEmail($mail)
+ {
+ if ($mail !== null && !filter_var($mail, FILTER_VALIDATE_EMAIL)) {
+ throw new InvalidArgumentException(
+ sprintf('Invalid mail given for user %s: %s', $this->getUsername(), $mail)
+ );
+ }
+
+ $this->email = $mail;
+ return $this;
+ }
+
+ /**
+ * Set the domain
+ *
+ * @param string $domain
+ *
+ * @return $this
+ */
+ public function setDomain($domain)
+ {
+ if ($domain && ($domain = trim($domain))) {
+ $this->domain = $domain;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get whether the user has a domain
+ *
+ * @return bool
+ */
+ public function hasDomain()
+ {
+ return $this->domain !== null;
+ }
+
+ /**
+ * Get the domain
+ *
+ * @return string
+ *
+ * @throws ProgrammingError If the user does not have a domain
+ */
+ public function getDomain()
+ {
+ if ($this->domain === null) {
+ throw new ProgrammingError(
+ 'User does not have a domain.'
+ . ' Use User::hasDomain() to check whether the user has a domain beforehand.'
+ );
+ }
+ return $this->domain;
+ }
+
+ /**
+ * Get the local username, ie. the username without its domain
+ *
+ * @return string
+ */
+ public function getLocalUsername()
+ {
+ return $this->localUsername;
+ }
+
+ /**
+ * Set additional information about user
+ *
+ * @param string $key
+ * @param string $value
+ *
+ * @return $this
+ */
+ public function setAdditional($key, $value)
+ {
+ $this->additionalInformation[$key] = $value;
+ return $this;
+ }
+
+ /**
+ * Getter for additional information
+ *
+ * @param string $key
+ * @return mixed|null
+ */
+ public function getAdditional($key)
+ {
+ if (isset($this->additionalInformation[$key])) {
+ return $this->additionalInformation[$key];
+ }
+
+ return null;
+ }
+
+ /**
+ * Retrieve the user's timezone
+ *
+ * If the user did not set a timezone, the default timezone set via config.ini is returned
+ *
+ * @return DateTimeZone
+ */
+ public function getTimeZone()
+ {
+ $tz = $this->preferences->get('timezone');
+ if ($tz === null) {
+ $tz = date_default_timezone_get();
+ }
+
+ return new DateTimeZone($tz);
+ }
+
+ /**
+ * Set additional external user information
+ *
+ * @param string $username
+ * @param string $field
+ *
+ * @return $this
+ */
+ public function setExternalUserInformation($username, $field)
+ {
+ $this->externalUserInformation = array($username, $field);
+ return $this;
+ }
+
+ /**
+ * Get additional external user information
+ *
+ * @return array
+ */
+ public function getExternalUserInformation()
+ {
+ return $this->externalUserInformation;
+ }
+
+ /**
+ * Return true if user has external user information set
+ *
+ * @return bool
+ */
+ public function isExternalUser()
+ {
+ return ! empty($this->externalUserInformation);
+ }
+
+ /**
+ * Get whether the user is authenticated using a HTTP authentication mechanism
+ *
+ * @return bool
+ */
+ public function getIsHttpUser()
+ {
+ return $this->isHttpUser;
+ }
+
+ /**
+ * Set whether the user is authenticated using a HTTP authentication mechanism
+ *
+ * @param bool $isHttpUser
+ *
+ * @return $this
+ */
+ public function setIsHttpUser($isHttpUser = true)
+ {
+ $this->isHttpUser = (bool) $isHttpUser;
+ return $this;
+ }
+
+ /**
+ * Whether the user has a given permission
+ *
+ * @param string $requiredPermission
+ *
+ * @return bool
+ */
+ public function can($requiredPermission)
+ {
+ list($permissions, $refusals) = AdmissionLoader::migrateLegacyPermissions([$requiredPermission]);
+ if (! empty($permissions)) {
+ $requiredPermission = array_pop($permissions);
+ } elseif (! empty($refusals)) {
+ throw new InvalidArgumentException(
+ 'Refusals are not supported anymore. Check for a grant instead!'
+ );
+ }
+
+ $granted = false;
+ foreach ($this->getRoles() as $role) {
+ if ($role->denies($requiredPermission)) {
+ return false;
+ }
+
+ if (! $granted && $role->grants($requiredPermission)) {
+ $granted = true;
+ }
+ }
+
+ return $granted;
+ }
+
+ /**
+ * Load and return this user's configured navigation of the given type
+ *
+ * @param string $type
+ *
+ * @return Navigation
+ */
+ public function getNavigation($type)
+ {
+ $config = Config::navigation($type === 'dashboard-pane' ? 'dashlet' : $type, $this->getUsername());
+
+ if ($type === 'dashboard-pane') {
+ $panes = array();
+ foreach ($config as $dashletName => $dashletConfig) {
+ // TODO: Throw ConfigurationError if pane or url is missing
+ $panes[$dashletConfig->pane][$dashletName] = $dashletConfig->url;
+ }
+
+ $navigation = new Navigation();
+ foreach ($panes as $paneName => $dashlets) {
+ $navigation->addItem(
+ $paneName,
+ array(
+ 'type' => 'dashboard-pane',
+ 'dashlets' => $dashlets
+ )
+ );
+ }
+ } else {
+ $navigation = Navigation::fromConfig($config);
+ }
+
+ return $navigation;
+ }
+}
diff --git a/library/Icinga/User/Preferences.php b/library/Icinga/User/Preferences.php
new file mode 100644
index 0000000..b09462b
--- /dev/null
+++ b/library/Icinga/User/Preferences.php
@@ -0,0 +1,169 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\User;
+
+use Countable;
+
+/**
+ * User preferences container
+ *
+ * Usage example:
+ * <code>
+ * <?php
+ *
+ * use Icinga\User\Preferences;
+ *
+ * $preferences = new Preferences(); // Start with empty preferences
+ *
+ * $preferences = new Preferences(array('aPreference' => 'value')); // Start with initial preferences
+ *
+ * $preferences->aNewPreference = 'value'; // Set a preference
+ *
+ * unset($preferences->aPreference); // Unset a preference
+ *
+ * // Retrieve a preference and return a default value if the preference does not exist
+ * $anotherPreference = $preferences->get('anotherPreference', 'defaultValue');
+ */
+class Preferences implements Countable
+{
+ /**
+ * Preferences key-value array
+ *
+ * @var array
+ */
+ protected $preferences = array();
+
+ /**
+ * Constructor
+ *
+ * @param array $preferences Preferences key-value array
+ */
+ public function __construct(array $preferences = array())
+ {
+ $this->preferences = $preferences;
+ }
+
+ /**
+ * Count all preferences
+ *
+ * @return int The number of preferences
+ */
+ public function count(): int
+ {
+ return count($this->preferences);
+ }
+
+ /**
+ * Determine whether a preference exists
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function has($name)
+ {
+ return array_key_exists($name, $this->preferences);
+ }
+
+ /**
+ * Write data to a preference
+ *
+ * @param string $name
+ * @param mixed $value
+ */
+ public function __set($name, $value)
+ {
+ $this->preferences[$name] = $value;
+ }
+
+ /**
+ * Retrieve a preference section
+ *
+ * @param string $name
+ *
+ * @return array|null
+ */
+ public function get($name)
+ {
+ if (array_key_exists($name, $this->preferences)) {
+ return $this->preferences[$name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Retrieve a value from a specific section
+ *
+ * @param string $section
+ * @param string $name
+ * @param null $default
+ *
+ * @return array|null
+ */
+ public function getValue($section, $name, $default = null)
+ {
+ if (array_key_exists($section, $this->preferences)
+ && array_key_exists($name, $this->preferences[$section])
+ ) {
+ return $this->preferences[$section][$name];
+ }
+
+ return $default;
+ }
+
+ /**
+ * Magic method so that $obj->value will work.
+ *
+ * @param string $name
+ *
+ * @return mixed
+ */
+ public function __get($name)
+ {
+ return $this->get($name);
+ }
+
+ /**
+ * Remove a given preference
+ *
+ * @param string $name Preference name
+ */
+ public function remove($name)
+ {
+ unset($this->preferences[$name]);
+ }
+
+ /**
+ * Determine if a preference is set and is not NULL
+ *
+ * @param string $name Preference name
+ *
+ * @return bool
+ */
+ public function __isset($name)
+ {
+ return isset($this->preferences[$name]);
+ }
+
+ /**
+ * Unset a given preference
+ *
+ * @param string $name Preference name
+ */
+ public function __unset($name)
+ {
+ $this->remove($name);
+ }
+
+ /**
+ * Get preferences as array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ return $this->preferences;
+ }
+}
diff --git a/library/Icinga/User/Preferences/PreferencesStore.php b/library/Icinga/User/Preferences/PreferencesStore.php
new file mode 100644
index 0000000..8ecc677
--- /dev/null
+++ b/library/Icinga/User/Preferences/PreferencesStore.php
@@ -0,0 +1,344 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\User\Preferences;
+
+use Exception;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\NotWritableError;
+use Icinga\User;
+use Icinga\User\Preferences;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+use Zend_Db_Expr;
+
+/**
+ * Preferences store factory
+ *
+ * Load and save user preferences by using a database
+ *
+ * Usage example:
+ * <code>
+ * <?php
+ *
+ * use Icinga\Data\ConfigObject;
+ * use Icinga\User\Preferences;
+ * use Icinga\User\Preferences\PreferencesStore;
+ *
+ * // Create a db store
+ * $store = PreferencesStore::create(
+ * new ConfigObject(
+ * 'resource' => 'resource name'
+ * ),
+ * $user // Instance of \Icinga\User
+ * );
+ *
+ * $preferences = new Preferences($store->load());
+ * $preferences->aPreference = 'value';
+ * $store->save($preferences);
+ * </code>
+ */
+class PreferencesStore
+{
+ /**
+ * Column name for username
+ */
+ const COLUMN_USERNAME = 'username';
+
+ /**
+ * Column name for section
+ */
+ const COLUMN_SECTION = 'section';
+
+ /**
+ * Column name for preference
+ */
+ const COLUMN_PREFERENCE = 'name';
+
+ /**
+ * Column name for value
+ */
+ const COLUMN_VALUE = 'value';
+
+ /**
+ * Column name for created time
+ */
+ const COLUMN_CREATED_TIME = 'ctime';
+
+ /**
+ * Column name for modified time
+ */
+ const COLUMN_MODIFIED_TIME = 'mtime';
+
+ /**
+ * Table name
+ *
+ * @var string
+ */
+ protected $table = 'icingaweb_user_preference';
+
+ /**
+ * Stored preferences
+ *
+ * @var array
+ */
+ protected $preferences = [];
+
+ /**
+ * Store config
+ *
+ * @var ConfigObject
+ */
+ protected $config;
+
+ /**
+ * Given user
+ *
+ * @var User
+ */
+ protected $user;
+
+ /**
+ * Create a new store
+ *
+ * @param ConfigObject $config The config for this adapter
+ * @param User $user The user to which these preferences belong
+ */
+ public function __construct(ConfigObject $config, User $user)
+ {
+ $this->config = $config;
+ $this->user = $user;
+ $this->init();
+ }
+
+ /**
+ * Getter for the store config
+ *
+ * @return ConfigObject
+ */
+ public function getStoreConfig(): ConfigObject
+ {
+ return $this->config;
+ }
+
+ /**
+ * Getter for the user
+ *
+ * @return User
+ */
+ public function getUser(): User
+ {
+ return $this->user;
+ }
+
+ /**
+ * Initialize the store
+ */
+ protected function init(): void
+ {
+ }
+
+ /**
+ * Load preferences from the database
+ *
+ * @return array
+ *
+ * @throws NotReadableError In case the database operation failed
+ */
+ public function load(): array
+ {
+ try {
+ $select = $this->getStoreConfig()->connection->getDbAdapter()->select();
+ $result = $select
+ ->from($this->table, [self::COLUMN_SECTION, self::COLUMN_PREFERENCE, self::COLUMN_VALUE])
+ ->where(self::COLUMN_USERNAME . ' = ?', $this->getUser()->getUsername())
+ ->query()
+ ->fetchAll();
+ } catch (Exception $e) {
+ throw new NotReadableError(
+ 'Cannot fetch preferences for user %s from database',
+ $this->getUser()->getUsername(),
+ $e
+ );
+ }
+
+ if ($result !== false) {
+ $values = [];
+ foreach ($result as $row) {
+ $values[$row->{self::COLUMN_SECTION}][$row->{self::COLUMN_PREFERENCE}] = $row->{self::COLUMN_VALUE};
+ }
+
+ $this->preferences = $values;
+ }
+
+ return $this->preferences;
+ }
+
+ /**
+ * Save the given preferences in the database
+ *
+ * @param Preferences $preferences The preferences to save
+ */
+ public function save(Preferences $preferences): void
+ {
+ $preferences = $preferences->toArray();
+
+ $sections = array_keys($preferences);
+
+ foreach ($sections as $section) {
+ if (! array_key_exists($section, $this->preferences)) {
+ $this->preferences[$section] = [];
+ }
+
+ if (! array_key_exists($section, $preferences)) {
+ $preferences[$section] = [];
+ }
+
+ $toBeInserted = array_diff_key($preferences[$section], $this->preferences[$section]);
+ if (!empty($toBeInserted)) {
+ $this->insert($toBeInserted, $section);
+ }
+
+ $toBeUpdated = array_intersect_key(
+ array_diff_assoc($preferences[$section], $this->preferences[$section]),
+ array_diff_assoc($this->preferences[$section], $preferences[$section])
+ );
+
+ if (!empty($toBeUpdated)) {
+ $this->update($toBeUpdated, $section);
+ }
+
+ $toBeDeleted = array_keys(array_diff_key($this->preferences[$section], $preferences[$section]));
+ if (!empty($toBeDeleted)) {
+ $this->delete($toBeDeleted, $section);
+ }
+ }
+ }
+
+ /**
+ * Insert the given preferences into the database
+ *
+ * @param array $preferences The preferences to insert
+ * @param string $section The preferences in section to update
+ *
+ * @throws NotWritableError In case the database operation failed
+ */
+ protected function insert(array $preferences, string $section): void
+ {
+ /** @var \Zend_Db_Adapter_Abstract $db */
+ $db = $this->getStoreConfig()->connection->getDbAdapter();
+
+ try {
+ foreach ($preferences as $key => $value) {
+ $db->insert(
+ $this->table,
+ [
+ self::COLUMN_USERNAME => $this->getUser()->getUsername(),
+ $db->quoteIdentifier(self::COLUMN_SECTION) => $section,
+ $db->quoteIdentifier(self::COLUMN_PREFERENCE) => $key,
+ self::COLUMN_VALUE => $value,
+ self::COLUMN_CREATED_TIME => new Zend_Db_Expr('NOW()'),
+ self::COLUMN_MODIFIED_TIME => new Zend_Db_Expr('NOW()')
+ ]
+ );
+ }
+ } catch (Exception $e) {
+ throw new NotWritableError(
+ 'Cannot insert preferences for user %s into database',
+ $this->getUser()->getUsername(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Update the given preferences in the database
+ *
+ * @param array $preferences The preferences to update
+ * @param string $section The preferences in section to update
+ *
+ * @throws NotWritableError In case the database operation failed
+ */
+ protected function update(array $preferences, string $section): void
+ {
+ /** @var \Zend_Db_Adapter_Abstract $db */
+ $db = $this->getStoreConfig()->connection->getDbAdapter();
+
+ try {
+ foreach ($preferences as $key => $value) {
+ $db->update(
+ $this->table,
+ [
+ self::COLUMN_VALUE => $value,
+ self::COLUMN_MODIFIED_TIME => new Zend_Db_Expr('NOW()')
+ ],
+ [
+ self::COLUMN_USERNAME . '=?' => $this->getUser()->getUsername(),
+ $db->quoteIdentifier(self::COLUMN_SECTION) . '=?' => $section,
+ $db->quoteIdentifier(self::COLUMN_PREFERENCE) . '=?' => $key
+ ]
+ );
+ }
+ } catch (Exception $e) {
+ throw new NotWritableError(
+ 'Cannot update preferences for user %s in database',
+ $this->getUser()->getUsername(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Delete the given preference names from the database
+ *
+ * @param array $preferenceKeys The preference names to delete
+ * @param string $section The preferences in section to update
+ *
+ * @throws NotWritableError In case the database operation failed
+ */
+ protected function delete(array $preferenceKeys, string $section): void
+ {
+ /** @var \Zend_Db_Adapter_Abstract $db */
+ $db = $this->getStoreConfig()->connection->getDbAdapter();
+
+ try {
+ $db->delete(
+ $this->table,
+ [
+ self::COLUMN_USERNAME . '=?' => $this->getUser()->getUsername(),
+ $db->quoteIdentifier(self::COLUMN_SECTION) . '=?' => $section,
+ $db->quoteIdentifier(self::COLUMN_PREFERENCE) . ' IN (?)' => $preferenceKeys
+ ]
+ );
+ } catch (Exception $e) {
+ throw new NotWritableError(
+ 'Cannot delete preferences for user %s from database',
+ $this->getUser()->getUsername(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Create preferences storage adapter from config
+ *
+ * @param ConfigObject $config The config for the adapter
+ * @param User $user The user to which these preferences belong
+ *
+ * @return self
+ *
+ * @throws ConfigurationError When the configuration defines an invalid storage type
+ */
+ public static function create(ConfigObject $config, User $user): self
+ {
+ $resourceConfig = ResourceFactory::getResourceConfig($config->resource);
+ if ($resourceConfig->db === 'mysql') {
+ $resourceConfig->charset = 'utf8mb4';
+ }
+
+ $config->connection = ResourceFactory::createResource($resourceConfig);
+
+ return new self($config, $user);
+ }
+}
diff --git a/library/Icinga/Util/ASN1.php b/library/Icinga/Util/ASN1.php
new file mode 100644
index 0000000..9e00258
--- /dev/null
+++ b/library/Icinga/Util/ASN1.php
@@ -0,0 +1,102 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+use DateInterval;
+use DateTime;
+use InvalidArgumentException;
+
+/**
+ * Parsers for ASN.1 types
+ */
+class ASN1
+{
+ /**
+ * Parse the given value based on the "3.3.13. Generalized Time" syntax as specified by IETF RFC 4517
+ *
+ * @param string $value
+ *
+ * @return DateTime
+ *
+ * @throws InvalidArgumentException
+ *
+ * @see https://tools.ietf.org/html/rfc4517#section-3.3.13
+ */
+ public static function parseGeneralizedTime($value)
+ {
+ $generalizedTimePattern = <<<EOD
+/\A
+ (?P<YmdH>
+ [0-9]{4} # century year
+ (?:0[1-9]|1[0-2]) # month
+ (?:0[1-9]|[12][0-9]|3[0-1]) # day
+ (?:[01][0-9]|2[0-3]) # hour
+ )
+ (?:
+ (?P<i>[0-5][0-9]) # minute
+ (?P<s>[0-5][0-9]|60)? # second or leap-second
+ )?
+ (?:[.,](?P<frac>[0-9]+))? # fraction
+ (?P<tz> # g-time-zone
+ Z
+ |
+ [-+]
+ (?:[01][0-9]|2[0-3]) # hour
+ (?:[0-5][0-9])? # minute
+ )
+\z/x
+EOD;
+
+ $matches = array();
+
+ if (preg_match($generalizedTimePattern, $value, $matches)) {
+ $dateTimeRaw = $matches['YmdH'];
+ $dateTimeFormat = 'YmdH';
+
+ if ($matches['i'] !== '') {
+ $dateTimeRaw .= $matches['i'];
+ $dateTimeFormat .= 'i';
+
+ if ($matches['s'] !== '') {
+ $dateTimeRaw .= $matches['s'];
+ $dateTimeFormat .= 's';
+ $fractionOfSeconds = 1;
+ } else {
+ $fractionOfSeconds = 60;
+ }
+ } else {
+ $fractionOfSeconds = 3600;
+ }
+
+ $dateTimeFormat .= 'O';
+
+ if ($matches['tz'] === 'Z') {
+ $dateTimeRaw .= '+0000';
+ } else {
+ $dateTimeRaw .= $matches['tz'];
+
+ if (strlen($matches['tz']) === 3) {
+ $dateTimeRaw .= '00';
+ }
+ }
+
+ $dateTime = DateTime::createFromFormat($dateTimeFormat, $dateTimeRaw);
+
+ if ($dateTime !== false) {
+ if (isset($matches['frac'])) {
+ $dateTime->add(new DateInterval(
+ 'PT' . round((float) ('0.' . $matches['frac']) * $fractionOfSeconds) . 'S'
+ ));
+ }
+
+ return $dateTime;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf(
+ 'Failed to parse %s based on the ASN.1 standard (GeneralizedTime)',
+ var_export($value, true)
+ ));
+ }
+}
diff --git a/library/Icinga/Util/Color.php b/library/Icinga/Util/Color.php
new file mode 100644
index 0000000..cf88f41
--- /dev/null
+++ b/library/Icinga/Util/Color.php
@@ -0,0 +1,121 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+/**
+ * Provide functions to change and convert colors.
+ */
+class Color
+{
+ /**
+ * Convert a given color string to an rgb-array containing
+ * each color as a decimal value.
+ *
+ * @param $color The color-string #RRGGBB
+ *
+ * @return array The converted rgb-array.
+ */
+ public static function rgbAsArray($color)
+ {
+ if (substr($color, 0, 1) !== '#') {
+ $color = '#' . $color;
+ }
+ if (strlen($color) !== 7) {
+ return;
+ }
+ $r = (float)intval(substr($color, 1, 2), 16);
+ $g = (float)intval(substr($color, 3, 2), 16);
+ $b = (float)intval(substr($color, 5, 2), 16);
+ return array($r, $g, $b);
+ }
+
+ /**
+ * Convert a rgb array to a color-string
+ *
+ * @param array $rgb The rgb-array
+ *
+ * @return string The color string #RRGGBB
+ */
+ public static function arrayToRgb(array $rgb)
+ {
+ $r = (string)dechex($rgb[0]);
+ $g = (string)dechex($rgb[1]);
+ $b = (string)dechex($rgb[2]);
+ return '#'
+ . (strlen($r) > 1 ? $r : '0' . $r)
+ . (strlen($g) > 1 ? $g : '0' . $g)
+ . (strlen($b) > 1 ? $b : '0' . $b);
+ }
+
+ /**
+ * Change the saturation for a given color.
+ *
+ * @param $color string The color to change
+ * @param $change float The change.
+ * 0.0 creates a black-and-white image.
+ * 0.5 reduces the color saturation by half.
+ * 1.0 causes no change.
+ * 2.0 doubles the color saturation.
+ * @return string
+ */
+ public static function changeSaturation($color, $change)
+ {
+ return self::arrayToRgb(self::changeRgbSaturation(self::rgbAsArray($color), $change));
+ }
+
+ /**
+ * Change the brightness for a given color
+ *
+ * @param $color string The color to change
+ * @param $change float The change in percent
+ *
+ * @return string
+ */
+ public static function changeBrightness($color, $change)
+ {
+ return self::arrayToRgb(self::changeRgbBrightness(self::rgbAsArray($color), $change));
+ }
+
+ /**
+ * @param $rgb array The rgb-array to change
+ * @param $change float The factor
+ *
+ * @return array The updated rgb-array
+ */
+ private static function changeRgbSaturation(array $rgb, $change)
+ {
+ $pr = 0.499; // 0.299
+ $pg = 0.387; // 0.587
+ $pb = 0.114; // 0.114
+ $r = $rgb[0];
+ $g = $rgb[1];
+ $b = $rgb[2];
+ $p = sqrt(
+ $r * $r * $pr +
+ $g * $g * $pg +
+ $b * $b * $pb
+ );
+ $rgb[0] = (int)($p + ($r - $p) * $change);
+ $rgb[1] = (int)($p + ($g - $p) * $change);
+ $rgb[2] = (int)($p + ($b - $p) * $change);
+ return $rgb;
+ }
+
+ /**
+ * @param $rgb array The rgb-array to change
+ * @param $change float The factor
+ *
+ * @return array The updated rgb-array
+ */
+ private static function changeRgbBrightness(array $rgb, $change)
+ {
+ $red = $rgb[0] + ($rgb[0] * $change);
+ $green = $rgb[1] + ($rgb[1] * $change);
+ $blue = $rgb[2] + ($rgb[2] * $change);
+ $rgb[0] = $red < 255 ? (int) $red : 255;
+ $rgb[1] = $green < 255 ? (int) $green : 255;
+ $rgb[2] = $blue < 255 ? (int) $blue : 255;
+ return $rgb;
+ }
+}
diff --git a/library/Icinga/Util/ConfigAwareFactory.php b/library/Icinga/Util/ConfigAwareFactory.php
new file mode 100644
index 0000000..133887a
--- /dev/null
+++ b/library/Icinga/Util/ConfigAwareFactory.php
@@ -0,0 +1,18 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+/**
+ * Interface defining a factory which is configured at runtime
+ */
+interface ConfigAwareFactory
+{
+ /**
+ * Set the factory's config
+ *
+ * @param mixed $config
+ * @throws \Icinga\Exception\ConfigurationError if the given config is not valid
+ */
+ public static function setConfig($config);
+}
diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php
new file mode 100644
index 0000000..bd275c6
--- /dev/null
+++ b/library/Icinga/Util/Csp.php
@@ -0,0 +1,107 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Util;
+
+use Icinga\Web\Response;
+use Icinga\Web\Window;
+use RuntimeException;
+
+use function ipl\Stdlib\get_php_type;
+
+/**
+ * Helper to enable strict content security policy (CSP)
+ *
+ * {@see static::addHeader()} adds a strict Content-Security-Policy header with a nonce to still support dynamic CSS
+ * securely.
+ * Note that {@see static::createNonce()} must be called first.
+ * Use {@see static::getStyleNonce()} to access the nonce for dynamic CSS.
+ *
+ * A nonce is not created for dynamic JS,
+ * and it is questionable whether this will ever be supported.
+ */
+class Csp
+{
+ /** @var static */
+ protected static $instance;
+
+ /** @var ?string */
+ protected $styleNonce;
+
+ /** Singleton */
+ private function __construct()
+ {
+ }
+
+ /**
+ * Add Content-Security-Policy header with a nonce for dynamic CSS
+ *
+ * Note that {@see static::createNonce()} must be called beforehand.
+ *
+ * @param Response $response
+ *
+ * @throws RuntimeException If no nonce set for CSS
+ */
+ public static function addHeader(Response $response): void
+ {
+ $csp = static::getInstance();
+
+ if (empty($csp->styleNonce)) {
+ throw new RuntimeException('No nonce set for CSS');
+ }
+
+ $response->setHeader('Content-Security-Policy', "style-src 'self' 'nonce-$csp->styleNonce';", true);
+ }
+
+ /**
+ * Set/recreate nonce for dynamic CSS
+ *
+ * Should always be called upon initial page loads or page reloads,
+ * as it sets/recreates a nonce for CSS and writes it to a window-aware session.
+ */
+ public static function createNonce(): void
+ {
+ $csp = static::getInstance();
+ $csp->styleNonce = base64_encode(random_bytes(16));
+
+ Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce);
+ }
+
+ /**
+ * Get nonce for dynamic CSS
+ *
+ * @return ?string
+ */
+ public static function getStyleNonce(): ?string
+ {
+ return static::getInstance()->styleNonce;
+ }
+
+ /**
+ * Get the CSP instance
+ *
+ * @return self
+ */
+ protected static function getInstance(): self
+ {
+ if (static::$instance === null) {
+ $csp = new static();
+ $nonce = Window::getInstance()->getSessionNamespace('csp')->get('style_nonce');
+ if ($nonce !== null && ! is_string($nonce)) {
+ throw new RuntimeException(
+ sprintf(
+ 'Nonce value is expected to be string, got %s instead',
+ get_php_type($nonce)
+ )
+ );
+ }
+
+ $csp->styleNonce = $nonce;
+
+ static::$instance = $csp;
+ }
+
+ return static::$instance;
+ }
+}
diff --git a/library/Icinga/Util/Dimension.php b/library/Icinga/Util/Dimension.php
new file mode 100644
index 0000000..6860fd8
--- /dev/null
+++ b/library/Icinga/Util/Dimension.php
@@ -0,0 +1,123 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+class Dimension
+{
+ /**
+ * Defines this dimension as nr of pixels
+ */
+ const UNIT_PX = "px";
+
+ /**
+ * Defines this dimension as width of 'M' in current font
+ */
+ const UNIT_EM = "em";
+
+ /**
+ * Defines this dimension as a percentage value
+ */
+ const UNIT_PERCENT = "%";
+
+ /**
+ * Defines this dimension in points
+ */
+ const UNIT_PT = "pt";
+
+ /**
+ * The current set value for this dimension
+ *
+ * @var int
+ */
+ private $value = 0;
+
+ /**
+ * The unit to interpret the value with
+ *
+ * @var string
+ */
+ private $unit = self::UNIT_PX;
+
+ /**
+ * Create a new Dimension object with the given size and unit
+ *
+ * @param int $value The new value
+ * @param string $unit The unit to use (default: px)
+ */
+ public function __construct($value, $unit = self::UNIT_PX)
+ {
+ $this->setValue($value, $unit);
+ }
+
+ /**
+ * Change the value and unit of this dimension
+ *
+ * @param int $value The new value
+ * @param string $unit The unit to use (default: px)
+ */
+ public function setValue($value, $unit = self::UNIT_PX)
+ {
+ $this->value = intval($value);
+ $this->unit = $unit;
+ }
+
+ /**
+ * Return true when the value is > 0
+ *
+ * @return bool
+ */
+ public function isDefined()
+ {
+ return $this->value > 0;
+ }
+
+ /**
+ * Return the underlying value without unit information
+ *
+ * @return int
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Return the unit used for the value
+ *
+ * @return string
+ */
+ public function getUnit()
+ {
+ return $this->unit;
+ }
+
+ /**
+ * Return this value with it's according unit as a string
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ if (!$this->isDefined()) {
+ return "";
+ }
+ return $this->value.$this->unit;
+ }
+
+ /**
+ * Create a new Dimension object from a string containing the numeric value and the dimension (e.g. 200px, 20%)
+ *
+ * @param $string The string to parse
+ *
+ * @return Dimension
+ */
+ public static function fromString($string)
+ {
+ $matches = array();
+ if (!preg_match_all('/^ *([0-9]+)(px|pt|em|\%) */i', $string, $matches)) {
+ return new Dimension(0);
+ }
+ return new Dimension(intval($matches[1][0]), $matches[2][0]);
+ }
+}
diff --git a/library/Icinga/Util/DirectoryIterator.php b/library/Icinga/Util/DirectoryIterator.php
new file mode 100644
index 0000000..cee37b6
--- /dev/null
+++ b/library/Icinga/Util/DirectoryIterator.php
@@ -0,0 +1,214 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+use ArrayIterator;
+use InvalidArgumentException;
+use RecursiveIterator;
+
+/**
+ * Iterator for traversing a directory
+ */
+class DirectoryIterator implements RecursiveIterator
+{
+ /**
+ * Iterate files first
+ *
+ * @var int
+ */
+ const FILES_FIRST = 1;
+
+ /**
+ * Current directory item
+ *
+ * @var string|false
+ */
+ private $current;
+
+ /**
+ * The file extension to filter for
+ *
+ * @var string
+ */
+ protected $extension;
+
+ /**
+ * Scanned files
+ *
+ * @var ArrayIterator
+ */
+ private $files;
+
+ /**
+ * Iterator flags
+ *
+ * @var int
+ */
+ protected $flags;
+
+ /**
+ * Current key
+ *
+ * @var string|false
+ */
+ private $key;
+
+ /**
+ * The path of the directory to traverse
+ *
+ * @var string
+ */
+ protected $path;
+
+ /**
+ * Directory queue if FILES_FIRST flag is set
+ *
+ * @var array
+ */
+ private $queue;
+
+ /**
+ * Whether to skip empty files
+ *
+ * Defaults to true.
+ *
+ * @var bool
+ */
+ protected $skipEmpty = true;
+
+ /**
+ * Whether to skip hidden files
+ *
+ * Defaults to true.
+ *
+ * @var bool
+ */
+ protected $skipHidden = true;
+
+ /**
+ * Create a new directory iterator from path
+ *
+ * The given path will not be validated whether it is readable. Use {@link isReadable()} before creating a new
+ * directory iterator instance.
+ *
+ * @param string $path The path of the directory to traverse
+ * @param string $extension The file extension to filter for. A leading dot is optional
+ * @param int $flags Iterator flags
+ */
+ public function __construct($path, $extension = null, $flags = null)
+ {
+ if (empty($path)) {
+ throw new InvalidArgumentException('The path can\'t be empty');
+ }
+ $this->path = $path;
+ if (! empty($extension)) {
+ $this->extension = '.' . ltrim($extension, '.');
+ }
+ if ($flags !== null) {
+ $this->flags = $flags;
+ }
+ }
+
+ /**
+ * Check whether the given path is a directory and is readable
+ *
+ * @param string $path The path of the directory
+ *
+ * @return bool
+ */
+ public static function isReadable($path)
+ {
+ return is_dir($path) && is_readable($path);
+ }
+
+ public function hasChildren(): bool
+ {
+ return static::isReadable($this->current);
+ }
+
+ public function getChildren(): DirectoryIterator
+ {
+ return new static($this->current, $this->extension, $this->flags);
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ return $this->current;
+ }
+
+ public function next(): void
+ {
+ $path = null;
+ do {
+ $this->files->next();
+ $skip = false;
+ if (! $this->files->valid()) {
+ $file = false;
+ $path = false;
+ break;
+ } else {
+ $file = $this->files->current();
+ do {
+ if ($this->skipHidden && $file[0] === '.') {
+ $skip = true;
+ break;
+ }
+
+ $path = $this->path . '/' . $file;
+
+ if (is_dir($path)) {
+ if ($this->flags & static::FILES_FIRST === static::FILES_FIRST) {
+ $this->queue[] = array($path, $file);
+ $skip = true;
+ }
+ break;
+ }
+
+ if ($this->skipEmpty && ! filesize($path)) {
+ $skip = true;
+ break;
+ }
+
+ if ($this->extension && ! StringHelper::endsWith($file, $this->extension)) {
+ $skip = true;
+ break;
+ }
+ } while (0);
+ }
+ } while ($skip);
+
+ /** @noinspection PhpUndefinedVariableInspection */
+
+ if ($path === false && ! empty($this->queue)) {
+ list($path, $file) = array_shift($this->queue);
+ }
+
+ $this->current = $path;
+ $this->key = $file;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->key;
+ }
+
+ public function valid(): bool
+ {
+ return $this->current !== false;
+ }
+
+ public function rewind(): void
+ {
+ if ($this->files === null) {
+ $files = scandir($this->path);
+ natcasesort($files);
+ $this->files = new ArrayIterator($files);
+ }
+ $this->files->rewind();
+ $this->queue = array();
+ $this->next();
+ }
+}
diff --git a/library/Icinga/Util/EnumeratingFilterIterator.php b/library/Icinga/Util/EnumeratingFilterIterator.php
new file mode 100644
index 0000000..0659961
--- /dev/null
+++ b/library/Icinga/Util/EnumeratingFilterIterator.php
@@ -0,0 +1,30 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+use FilterIterator;
+
+/**
+ * Class EnumeratingFilterIterator
+ *
+ * FilterIterator with continuous numeric key (index)
+ */
+abstract class EnumeratingFilterIterator extends FilterIterator
+{
+ /**
+ * @var int
+ */
+ private $index;
+
+ public function rewind(): void
+ {
+ parent::rewind();
+ $this->index = 0;
+ }
+
+ public function key(): int
+ {
+ return $this->index++;
+ }
+}
diff --git a/library/Icinga/Util/Environment.php b/library/Icinga/Util/Environment.php
new file mode 100644
index 0000000..8d47b84
--- /dev/null
+++ b/library/Icinga/Util/Environment.php
@@ -0,0 +1,42 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+/**
+ * Helper for configuring the PHP environment
+ */
+class Environment
+{
+ /**
+ * Raise the PHP memory_limit
+ *
+ * Unless it is not already set to a higher limit
+ *
+ * @param string|int $minimum
+ */
+ public static function raiseMemoryLimit($minimum = '512M')
+ {
+ if (is_string($minimum)) {
+ $minimum = Format::unpackShorthandBytes($minimum);
+ }
+
+ if (Format::unpackShorthandBytes(ini_get('memory_limit')) < $minimum) {
+ ini_set('memory_limit', $minimum);
+ }
+ }
+
+ /**
+ * Raise the PHP max_execution_time
+ *
+ * Unless it is not already configured to a higher value.
+ *
+ * @param int $minimum
+ */
+ public static function raiseExecutionTime($minimum = 300)
+ {
+ if ((int) ini_get('max_execution_time') < $minimum) {
+ ini_set('max_execution_time', $minimum);
+ }
+ }
+}
diff --git a/library/Icinga/Util/File.php b/library/Icinga/Util/File.php
new file mode 100644
index 0000000..dad332a
--- /dev/null
+++ b/library/Icinga/Util/File.php
@@ -0,0 +1,195 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+use SplFileObject;
+use ErrorException;
+use RuntimeException;
+use Icinga\Exception\NotWritableError;
+
+/**
+ * File
+ *
+ * A class to ease opening files and reading/writing to them.
+ */
+class File extends SplFileObject
+{
+ /**
+ * The mode used to open the file
+ *
+ * @var string
+ */
+ protected $openMode;
+
+ /**
+ * The access mode to use when creating directories
+ *
+ * @var int
+ */
+ public static $dirMode = 1528; // 2770
+
+ /**
+ * @see SplFileObject::__construct()
+ */
+ public function __construct($filename, $openMode = 'r', $useIncludePath = false, $context = null)
+ {
+ $this->openMode = $openMode;
+ if ($context === null) {
+ parent::__construct($filename, $openMode, $useIncludePath);
+ } else {
+ parent::__construct($filename, $openMode, $useIncludePath, $context);
+ }
+ }
+
+ /**
+ * Create a file using the given access mode and return a instance of File open for writing
+ *
+ * @param string $path The path to the file
+ * @param int $accessMode The access mode to set
+ * @param bool $recursive Whether missing nested directories of the given path should be created
+ *
+ * @return File
+ *
+ * @throws RuntimeException In case the file cannot be created or the access mode cannot be set
+ * @throws NotWritableError In case the path's (existing) parent is not writable
+ */
+ public static function create($path, $accessMode, $recursive = true)
+ {
+ $dirPath = dirname($path);
+ if ($recursive && !is_dir($dirPath)) {
+ static::createDirectories($dirPath);
+ } elseif (! is_writable($dirPath)) {
+ throw new NotWritableError(sprintf('Path "%s" is not writable', $dirPath));
+ }
+
+ $file = new static($path, 'x+');
+
+ if (! @chmod($path, $accessMode)) {
+ $error = error_get_last();
+ throw new RuntimeException(sprintf(
+ 'Cannot set access mode "%s" on file "%s" (%s)',
+ decoct($accessMode),
+ $path,
+ $error['message']
+ ));
+ }
+
+ return $file;
+ }
+
+ /**
+ * Create missing directories
+ *
+ * @param string $path
+ *
+ * @throws RuntimeException In case a directory cannot be created or the access mode cannot be set
+ */
+ protected static function createDirectories($path)
+ {
+ $part = strpos($path, DIRECTORY_SEPARATOR) === 0 ? DIRECTORY_SEPARATOR : '';
+ foreach (explode(DIRECTORY_SEPARATOR, ltrim($path, DIRECTORY_SEPARATOR)) as $dir) {
+ $part .= $dir . DIRECTORY_SEPARATOR;
+
+ if (! is_dir($part)) {
+ if (! @mkdir($part, static::$dirMode)) {
+ $error = error_get_last();
+ throw new RuntimeException(sprintf(
+ 'Failed to create missing directory "%s" (%s)',
+ $part,
+ $error['message']
+ ));
+ }
+
+ if (! @chmod($part, static::$dirMode)) {
+ $error = error_get_last();
+ throw new RuntimeException(sprintf(
+ 'Failed to set access mode "%s" for directory "%s" (%s)',
+ decoct(static::$dirMode),
+ $part,
+ $error['message']
+ ));
+ }
+ }
+ }
+ }
+
+ #[\ReturnTypeWillChange]
+ public function fwrite($str, $length = null)
+ {
+ $this->assertOpenForWriting();
+ $this->setupErrorHandler();
+ $retVal = $length === null ? parent::fwrite($str) : parent::fwrite($str, $length);
+ restore_error_handler();
+ return $retVal;
+ }
+
+ public function ftruncate($size): bool
+ {
+ $this->assertOpenForWriting();
+ $this->setupErrorHandler();
+ $retVal = parent::ftruncate($size);
+ restore_error_handler();
+ return $retVal;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function ftell()
+ {
+ $this->setupErrorHandler();
+ $retVal = parent::ftell();
+ restore_error_handler();
+ return $retVal;
+ }
+
+ public function flock($operation, &$wouldblock = null): bool
+ {
+ $this->setupErrorHandler();
+ $retVal = parent::flock($operation, $wouldblock);
+ restore_error_handler();
+ return $retVal;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function fgetc()
+ {
+ $this->setupErrorHandler();
+ $retVal = parent::fgetc();
+ restore_error_handler();
+ return $retVal;
+ }
+
+ public function fflush(): bool
+ {
+ $this->setupErrorHandler();
+ $retVal = parent::fflush();
+ restore_error_handler();
+ return $retVal;
+ }
+
+ /**
+ * Setup an error handler that throws a RuntimeException for every emitted E_WARNING
+ */
+ protected function setupErrorHandler()
+ {
+ set_error_handler(
+ function ($errno, $errstr, $errfile, $errline) {
+ restore_error_handler();
+ throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
+ },
+ E_WARNING
+ );
+ }
+
+ /**
+ * Assert that the file was opened for writing and throw an exception otherwise
+ *
+ * @throws NotWritableError In case the file was not opened for writing
+ */
+ protected function assertOpenForWriting()
+ {
+ if (!preg_match('@w|a|\+@', $this->openMode)) {
+ throw new NotWritableError('File not open for writing');
+ }
+ }
+}
diff --git a/library/Icinga/Util/Format.php b/library/Icinga/Util/Format.php
new file mode 100644
index 0000000..1158208
--- /dev/null
+++ b/library/Icinga/Util/Format.php
@@ -0,0 +1,197 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+use DateTime;
+
+class Format
+{
+ const STANDARD_IEC = 0;
+ const STANDARD_SI = 1;
+ protected static $instance;
+
+ protected static $bitPrefix = array(
+ array('bit', 'Kibit', 'Mibit', 'Gibit', 'Tibit', 'Pibit', 'Eibit', 'Zibit', 'Yibit'),
+ array('bit', 'kbit', 'Mbit', 'Gbit', 'Tbit', 'Pbit', 'Ebit', 'Zbit', 'Ybit'),
+ );
+ protected static $bitBase = array(1024, 1000);
+
+ protected static $bytePrefix = array(
+ array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'),
+ array('B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'),
+ );
+ protected static $byteBase = array(1024, 1000);
+
+ protected static $secondPrefix = array('s', 'ms', 'µs', 'ns', 'ps', 'fs', 'as');
+ protected static $secondBase = 1000;
+
+ public static function getInstance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new Format;
+ }
+ return self::$instance;
+ }
+
+ public static function bits($value, $standard = self::STANDARD_SI)
+ {
+ return self::formatForUnits(
+ $value,
+ self::$bitPrefix[$standard],
+ self::$bitBase[$standard]
+ );
+ }
+
+ public static function bytes($value, $standard = self::STANDARD_IEC)
+ {
+ return self::formatForUnits(
+ $value,
+ self::$bytePrefix[$standard],
+ self::$byteBase[$standard]
+ );
+ }
+
+ public static function seconds($value)
+ {
+ if ($value === null) {
+ return '';
+ }
+
+ $absValue = abs($value);
+
+ if ($absValue < 60) {
+ return self::formatForUnits($value, self::$secondPrefix, self::$secondBase);
+ } elseif ($absValue < 3600) {
+ return sprintf('%0.2f m', $value / 60);
+ } elseif ($absValue < 86400) {
+ return sprintf('%0.2f h', $value / 3600);
+ }
+
+ // TODO: Do we need weeks, months and years?
+ return sprintf('%0.2f d', $value / 86400);
+ }
+
+ protected static function formatForUnits($value, &$units, $base)
+ {
+ if ($value === null) {
+ return '';
+ }
+
+ $sign = '';
+ if ($value < 0) {
+ $value = abs($value);
+ $sign = '-';
+ }
+
+ if ($value == 0) {
+ $pow = $result = 0;
+ } else {
+ $pow = floor(log($value, $base));
+ $result = $value / pow($base, $pow);
+ }
+
+ // 1034.23 looks better than 1.03, but 2.03 is fine:
+ if ($pow > 0 && $result < 2) {
+ $result = $value / pow($base, --$pow);
+ }
+
+ return sprintf(
+ '%s%0.2f %s',
+ $sign,
+ $result,
+ $units[abs($pow)]
+ );
+ }
+
+ /**
+ * Return the amount of seconds based on the given month
+ *
+ * @param DateTime|int $dateTimeOrTimestamp The date and time to use
+ *
+ * @return int
+ */
+ public static function secondsByMonth($dateTimeOrTimestamp)
+ {
+ if ($dateTimeOrTimestamp === null) {
+ return 0;
+ }
+
+ if (!($dt = $dateTimeOrTimestamp) instanceof DateTime) {
+ $dt = new DateTime();
+ $dt->setTimestamp($dateTimeOrTimestamp);
+ }
+
+ return (int) $dt->format('t') * 24 * 3600;
+ }
+
+ /**
+ * Return the amount of seconds based on the given year
+ *
+ * @param DateTime|int $dateTimeOrTimestamp The date and time to use
+ *
+ * @return int
+ */
+ public static function secondsByYear($dateTimeOrTimestamp)
+ {
+ if ($dateTimeOrTimestamp === null) {
+ return 0;
+ }
+
+ return (self::isLeapYear($dateTimeOrTimestamp) ? 366 : 365) * 24 * 3600;
+ }
+
+ /**
+ * Return whether the given year is a leap year
+ *
+ * @param DateTime|int $dateTimeOrTimestamp The date and time to use
+ *
+ * @return bool
+ */
+ public static function isLeapYear($dateTimeOrTimestamp)
+ {
+ if ($dateTimeOrTimestamp === null) {
+ return false;
+ }
+
+ if (!($dt = $dateTimeOrTimestamp) instanceof DateTime) {
+ $dt = new DateTime();
+ $dt->setTimestamp($dateTimeOrTimestamp);
+ }
+
+ return $dt->format('L') == 1;
+ }
+
+ /**
+ * Unpack shorthand bytes PHP directives to bytes
+ *
+ * @param string $subject
+ *
+ * @return int
+ */
+ public static function unpackShorthandBytes($subject)
+ {
+ $base = (int) $subject;
+
+ if ($base <= -1) {
+ return INF;
+ }
+
+ switch (strtoupper($subject[strlen($subject) - 1])) {
+ case 'K':
+ $multiplier = 1024;
+ break;
+ case 'M':
+ $multiplier = 1024 ** 2;
+ break;
+ case 'G':
+ $multiplier = 1024 ** 3;
+ break;
+ default:
+ $multiplier = 1;
+ break;
+ }
+
+ return $base * $multiplier;
+ }
+}
diff --git a/library/Icinga/Util/GlobFilter.php b/library/Icinga/Util/GlobFilter.php
new file mode 100644
index 0000000..ac0493a
--- /dev/null
+++ b/library/Icinga/Util/GlobFilter.php
@@ -0,0 +1,182 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+use stdClass;
+
+/**
+ * GLOB-like filter for simple data structures
+ *
+ * e.g. this filters:
+ *
+ * foo.bar.baz
+ * foo.b*r.baz
+ * **.baz
+ *
+ * match this one:
+ *
+ * array(
+ * 'foo' => array(
+ * 'bar' => array(
+ * 'baz' => 'deadbeef' // <---
+ * )
+ * )
+ * )
+ */
+class GlobFilter
+{
+ /**
+ * The prepared filters
+ *
+ * @var array
+ */
+ protected $filters;
+
+ /**
+ * Create a new filter from a comma-separated list of GLOB-like filters or an array of such lists.
+ *
+ * @param string|\Traversable|iterable $filters
+ */
+ public function __construct($filters)
+ {
+ $patterns = array(array(''));
+ $lastIndex1 = $lastIndex2 = 0;
+
+ foreach ((is_string($filters) ? array($filters) : $filters) as $rawPatterns) {
+ $escape = false;
+
+ foreach (str_split($rawPatterns) as $c) {
+ if ($escape) {
+ $escape = false;
+ $patterns[$lastIndex1][$lastIndex2] .= preg_quote($c, '/');
+ } else {
+ switch ($c) {
+ case '\\':
+ $escape = true;
+ break;
+ case ',':
+ $patterns[] = array('');
+ ++$lastIndex1;
+ $lastIndex2 = 0;
+ break;
+ case '.':
+ $patterns[$lastIndex1][] = '';
+ ++$lastIndex2;
+ break;
+ case '*':
+ $patterns[$lastIndex1][$lastIndex2] .= '.*';
+ break;
+ default:
+ $patterns[$lastIndex1][$lastIndex2] .= preg_quote($c, '/');
+ }
+ }
+ }
+
+ if ($escape) {
+ $patterns[$lastIndex1][$lastIndex2] .= '\\\\';
+ }
+ }
+
+ $this->filters = array();
+
+ foreach ($patterns as $pattern) {
+ foreach ($pattern as $i => $subPattern) {
+ if ($subPattern === '') {
+ unset($pattern[$i]);
+ } elseif ($subPattern === '.*.*') {
+ $pattern[$i] = '**';
+ } elseif ($subPattern === '.*') {
+ $pattern[$i] = '/^' . $subPattern . '$/';
+ } else {
+ $pattern[$i] = '/^' . trim($subPattern) . '$/i';
+ }
+ }
+
+ if (! empty($pattern)) {
+ $found = false;
+ foreach ($pattern as $i => $v) {
+ if ($found) {
+ if ($v === '**') {
+ unset($pattern[$i]);
+ } else {
+ $found = false;
+ }
+ } elseif ($v === '**') {
+ $found = true;
+ }
+ }
+
+ if (end($pattern) === '**') {
+ $pattern[] = '/^.*$/';
+ }
+
+ $this->filters[] = array_values($pattern);
+ }
+ }
+ }
+
+ /**
+ * Remove all keys/attributes matching any of $this->filters from $dataStructure
+ *
+ * @param stdClass|array $dataStructure
+ *
+ * @return stdClass|array The modified copy of $dataStructure
+ */
+ public function removeMatching($dataStructure)
+ {
+ foreach ($this->filters as $filter) {
+ $dataStructure = static::removeMatchingRecursive($dataStructure, $filter);
+ }
+ return $dataStructure;
+ }
+
+ /**
+ * Helper method for removeMatching()
+ *
+ * @param stdClass|array $dataStructure
+ * @param array $filter
+ *
+ * @return stdClass|array
+ */
+ protected static function removeMatchingRecursive($dataStructure, $filter)
+ {
+ $multiLevelPattern = $filter[0] === '**';
+ if ($multiLevelPattern) {
+ $dataStructure = static::removeMatchingRecursive($dataStructure, array_slice($filter, 1));
+ }
+
+ $isObject = $dataStructure instanceof stdClass;
+ if ($isObject || is_array($dataStructure)) {
+ if ($isObject) {
+ $dataStructure = (array) $dataStructure;
+ }
+
+ if ($multiLevelPattern) {
+ foreach ($dataStructure as $k => & $v) {
+ $v = static::removeMatchingRecursive($v, $filter);
+ unset($v);
+ }
+ } else {
+ $currentLevel = $filter[0];
+ $nextLevels = count($filter) === 1 ? null : array_slice($filter, 1);
+ foreach ($dataStructure as $k => & $v) {
+ if (preg_match($currentLevel, (string) $k)) {
+ if ($nextLevels === null) {
+ unset($dataStructure[$k]);
+ } else {
+ $v = static::removeMatchingRecursive($v, $nextLevels);
+ }
+ }
+ unset($v);
+ }
+ }
+
+ if ($isObject) {
+ $dataStructure = (object) $dataStructure;
+ }
+ }
+
+ return $dataStructure;
+ }
+}
diff --git a/library/Icinga/Util/Json.php b/library/Icinga/Util/Json.php
new file mode 100644
index 0000000..0b89dcc
--- /dev/null
+++ b/library/Icinga/Util/Json.php
@@ -0,0 +1,151 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Exception\Json\JsonEncodeException;
+
+/**
+ * Wrap {@link json_encode()} and {@link json_decode()} with error handling
+ */
+class Json
+{
+ /**
+ * {@link json_encode()} wrapper
+ *
+ * @param mixed $value
+ * @param int $options
+ * @param int $depth
+ *
+ * @return string
+ * @throws JsonEncodeException
+ */
+ public static function encode($value, $options = 0, $depth = 512)
+ {
+ return static::encodeAndSanitize($value, $options, $depth, false);
+ }
+
+ /**
+ * {@link json_encode()} wrapper, automatically sanitizes bad UTF-8
+ *
+ * @param mixed $value
+ * @param int $options
+ * @param int $depth
+ *
+ * @return string
+ * @throws JsonEncodeException
+ */
+ public static function sanitize($value, $options = 0, $depth = 512)
+ {
+ return static::encodeAndSanitize($value, $options, $depth, true);
+ }
+
+ /**
+ * {@link json_encode()} wrapper, sanitizes bad UTF-8
+ *
+ * @param mixed $value
+ * @param int $options
+ * @param int $depth
+ * @param bool $autoSanitize Automatically sanitize invalid UTF-8 (if any)
+ *
+ * @return string
+ * @throws JsonEncodeException
+ */
+ protected static function encodeAndSanitize($value, $options, $depth, $autoSanitize)
+ {
+ $encoded = json_encode($value, $options, $depth);
+
+ switch (json_last_error()) {
+ case JSON_ERROR_NONE:
+ return $encoded;
+
+ /** @noinspection PhpMissingBreakStatementInspection */
+ case JSON_ERROR_UTF8:
+ if ($autoSanitize) {
+ return static::encode(static::sanitizeUtf8Recursive($value), $options, $depth);
+ }
+ // Fallthrough
+
+ default:
+ throw new JsonEncodeException('%s: %s', json_last_error_msg(), var_export($value, true));
+ }
+ }
+
+ /**
+ * {@link json_decode()} wrapper
+ *
+ * @param string $json
+ * @param bool $assoc
+ * @param int $depth
+ * @param int $options
+ *
+ * @return mixed
+ * @throws JsonDecodeException
+ */
+ public static function decode($json, $assoc = false, $depth = 512, $options = 0)
+ {
+ $decoded = $json ? json_decode($json, $assoc, $depth, $options) : null;
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new JsonDecodeException('%s: %s', json_last_error_msg(), var_export($json, true));
+ }
+ return $decoded;
+ }
+
+ /**
+ * Replace bad byte sequences in UTF-8 strings inside the given JSON-encodable structure with question marks
+ *
+ * @param mixed $value
+ *
+ * @return mixed
+ */
+ protected static function sanitizeUtf8Recursive($value)
+ {
+ switch (gettype($value)) {
+ case 'string':
+ return static::sanitizeUtf8String($value);
+
+ case 'array':
+ $sanitized = array();
+
+ foreach ($value as $key => $val) {
+ if (is_string($key)) {
+ $key = static::sanitizeUtf8String($key);
+ }
+
+ $sanitized[$key] = static::sanitizeUtf8Recursive($val);
+ }
+
+ return $sanitized;
+
+ case 'object':
+ $sanitized = array();
+
+ foreach ($value as $key => $val) {
+ if (is_string($key)) {
+ $key = static::sanitizeUtf8String($key);
+ }
+
+ $sanitized[$key] = static::sanitizeUtf8Recursive($val);
+ }
+
+ return (object) $sanitized;
+
+ default:
+ return $value;
+ }
+ }
+
+ /**
+ * Replace bad byte sequences in the given UTF-8 string with question marks
+ *
+ * @param string $string
+ *
+ * @return string
+ */
+ protected static function sanitizeUtf8String($string)
+ {
+ return mb_convert_encoding($string, 'UTF-8', 'UTF-8');
+ }
+}
diff --git a/library/Icinga/Util/LessParser.php b/library/Icinga/Util/LessParser.php
new file mode 100644
index 0000000..1e07aa9
--- /dev/null
+++ b/library/Icinga/Util/LessParser.php
@@ -0,0 +1,15 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Util;
+
+use Icinga\Less\Visitor;
+use lessc;
+
+class LessParser extends lessc
+{
+ public function __construct()
+ {
+ $this->setOption('plugins', [new Visitor()]);
+ }
+}
diff --git a/library/Icinga/Util/StringHelper.php b/library/Icinga/Util/StringHelper.php
new file mode 100644
index 0000000..67a836b
--- /dev/null
+++ b/library/Icinga/Util/StringHelper.php
@@ -0,0 +1,184 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+/**
+ * Common string functions
+ */
+class StringHelper
+{
+ /**
+ * Split string into an array and trim spaces
+ *
+ * @param string $value
+ * @param string $delimiter
+ * @param int $limit
+ *
+ * @return array
+ */
+ public static function trimSplit($value, $delimiter = ',', $limit = null)
+ {
+ if ($value === null) {
+ return [];
+ }
+
+ if ($limit !== null) {
+ $exploded = explode($delimiter, $value, $limit);
+ } else {
+ $exploded = explode($delimiter, $value);
+ }
+
+ return array_map('trim', $exploded);
+ }
+
+ /**
+ * Uppercase the first character of each word in a string
+ *
+ * Converts 'first_name' to 'FirstName' for example.
+ *
+ * @param string $name
+ * @param string $separator Word separator
+ *
+ * @return string
+ */
+ public static function cname($name, $separator = '_')
+ {
+ if ($name === null) {
+ return '';
+ }
+
+ return str_replace(' ', '', ucwords(str_replace($separator, ' ', strtolower($name))));
+ }
+
+ /**
+ * Add ellipsis when a string is longer than max length
+ *
+ * @param string $string
+ * @param int $maxLength
+ * @param string $ellipsis
+ *
+ * @return string
+ */
+ public static function ellipsis($string, $maxLength, $ellipsis = '...')
+ {
+ if ($string === null) {
+ return '';
+ }
+
+ if (strlen($string) > $maxLength) {
+ return substr($string, 0, $maxLength - strlen($ellipsis)) . $ellipsis;
+ }
+
+ return $string;
+ }
+
+ /**
+ * Add ellipsis in the center of a string when a string is longer than max length
+ *
+ * @param string $string
+ * @param int $maxLength
+ * @param string $ellipsis
+ *
+ * @return string
+ */
+ public static function ellipsisCenter($string, $maxLength, $ellipsis = '...')
+ {
+ if ($string === null) {
+ return '';
+ }
+
+ $start = ceil($maxLength / 2.0);
+ $end = floor($maxLength / 2.0);
+ if (strlen($string) > $maxLength) {
+ return substr($string, 0, $start - strlen($ellipsis)) . $ellipsis . substr($string, - $end);
+ }
+
+ return $string;
+ }
+
+ /**
+ * Find and return all similar strings in $possibilites matching $string with the given minimum $similarity
+ *
+ * @param string $string
+ * @param array $possibilities
+ * @param float $similarity
+ *
+ * @return array
+ */
+ public static function findSimilar($string, array $possibilities, $similarity = 0.33)
+ {
+ if (empty($string)) {
+ return array();
+ }
+
+ $matches = array();
+ foreach ($possibilities as $possibility) {
+ $distance = levenshtein($string, $possibility);
+ if ($distance / strlen($string) <= $similarity) {
+ $matches[] = $possibility;
+ }
+ }
+
+ return $matches;
+ }
+
+ /**
+ * Test whether the given string ends with the given suffix
+ *
+ * @param string $string The string to test
+ * @param string $suffix The suffix the string must end with
+ *
+ * @return bool
+ */
+ public static function endsWith($string, $suffix)
+ {
+ if ($string === null) {
+ return false;
+ }
+
+ $stringSuffix = substr($string, -strlen($suffix));
+ return $stringSuffix !== false ? $stringSuffix === $suffix : false;
+ }
+
+ /**
+ * Generates an array of strings that constitutes the cartesian product of all passed sets, with all
+ * string combinations concatenated using the passed join-operator.
+ *
+ * <pre>
+ * cartesianProduct(
+ * array(array('foo', 'bar'), array('mumble', 'grumble', null)),
+ * '_'
+ * );
+ * => array('foo_mumble', 'foo_grumble', 'bar_mumble', 'bar_grumble', 'foo', 'bar')
+ * </pre>
+ *
+ * @param array $sets An array of arrays containing all sets for which the cartesian
+ * product should be calculated.
+ * @param string $glue The glue used to join the strings, defaults to ''.
+ *
+ * @returns array The cartesian product in one array of strings.
+ */
+ public static function cartesianProduct(array $sets, $glue = '')
+ {
+ $product = null;
+ foreach ($sets as $set) {
+ if (! isset($product)) {
+ $product = $set;
+ } else {
+ $newProduct = array();
+ foreach ($product as $strA) {
+ foreach ($set as $strB) {
+ if ($strB === null) {
+ $newProduct []= $strA;
+ } else {
+ $newProduct []= $strA . $glue . $strB;
+ }
+ }
+ }
+ $product = $newProduct;
+ }
+ }
+ return $product;
+ }
+}
diff --git a/library/Icinga/Util/TimezoneDetect.php b/library/Icinga/Util/TimezoneDetect.php
new file mode 100644
index 0000000..4967c7f
--- /dev/null
+++ b/library/Icinga/Util/TimezoneDetect.php
@@ -0,0 +1,107 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Util;
+
+/**
+ * Retrieve timezone information from cookie
+ */
+class TimezoneDetect
+{
+ /**
+ * If detection was successful
+ *
+ * @var bool
+ */
+ private static $success;
+
+ /**
+ * Timezone offset in minutes
+ *
+ * @var int
+ */
+ private static $offset = 0;
+
+ /**
+ * @var string
+ */
+ private static $timezoneName;
+
+ /**
+ * Cookie name
+ *
+ * @var string
+ */
+ public static $cookieName = 'icingaweb2-tzo';
+
+ /**
+ * Timezone name
+ *
+ * @var string
+ */
+ private static $timezone;
+
+ /**
+ * Create new object and try to identify the timezone
+ */
+ public function __construct()
+ {
+ if (self::$success !== null) {
+ return;
+ }
+
+ if (array_key_exists(self::$cookieName, $_COOKIE)) {
+ $matches = array();
+ if (preg_match('/\A(-?\d+)[\-,](\d+)\z/', $_COOKIE[self::$cookieName], $matches)) {
+ $offset = $matches[1];
+ $timezoneName = timezone_name_from_abbr('', (int) $offset, (int) $matches[2]);
+
+ self::$success = (bool) $timezoneName;
+ if (self::$success) {
+ self::$offset = $offset;
+ self::$timezoneName = $timezoneName;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get offset
+ *
+ * @return int
+ */
+ public function getOffset()
+ {
+ return self::$offset;
+ }
+
+ /**
+ * Get timezone name
+ *
+ * @return string
+ */
+ public function getTimezoneName()
+ {
+ return self::$timezoneName;
+ }
+
+ /**
+ * True on success
+ *
+ * @return bool
+ */
+ public function success()
+ {
+ return self::$success;
+ }
+
+ /**
+ * Reset object
+ */
+ public function reset()
+ {
+ self::$success = null;
+ self::$timezoneName = null;
+ self::$offset = 0;
+ }
+}
diff --git a/library/Icinga/Web/Announcement.php b/library/Icinga/Web/Announcement.php
new file mode 100644
index 0000000..9835ce0
--- /dev/null
+++ b/library/Icinga/Web/Announcement.php
@@ -0,0 +1,158 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+/**
+ * An announcement to be displayed prominently in the web UI
+ */
+class Announcement
+{
+ /**
+ * @var string
+ */
+ protected $author;
+
+ /**
+ * @var string
+ */
+ protected $message;
+
+ /**
+ * @var int
+ */
+ protected $start;
+
+ /**
+ * @var int
+ */
+ protected $end;
+
+ /**
+ * Hash of the message
+ *
+ * @var string|null
+ */
+ protected $hash = null;
+
+ /**
+ * Announcement constructor
+ *
+ * @param array $properties
+ */
+ public function __construct(array $properties = array())
+ {
+ foreach ($properties as $key => $value) {
+ $method = 'set' . ucfirst($key);
+ if (method_exists($this, $method)) {
+ $this->$method($value);
+ }
+ }
+ }
+
+ /**
+ * Get the author of the acknowledged
+ *
+ * @return string
+ */
+ public function getAuthor()
+ {
+ return $this->author;
+ }
+
+ /**
+ * Set the author of the acknowledged
+ *
+ * @param string $author
+ *
+ * @return $this
+ */
+ public function setAuthor($author)
+ {
+ $this->author = $author;
+ return $this;
+ }
+
+ /**
+ * Get the message of the acknowledged
+ *
+ * @return string
+ */
+ public function getMessage()
+ {
+ return $this->message;
+ }
+
+ /**
+ * Set the message of the acknowledged
+ *
+ * @param string $message
+ *
+ * @return $this
+ */
+ public function setMessage($message)
+ {
+ $this->message = $message;
+ $this->hash = null;
+ return $this;
+ }
+
+ /**
+ * Get the start date and time of the acknowledged
+ *
+ * @return int
+ */
+ public function getStart()
+ {
+ return $this->start;
+ }
+
+ /**
+ * Set the start date and time of the acknowledged
+ *
+ * @param int $start
+ *
+ * @return $this
+ */
+ public function setStart($start)
+ {
+ $this->start = $start;
+ return $this;
+ }
+
+ /**
+ * Get the end date and time of the acknowledged
+ *
+ * @return int
+ */
+ public function getEnd()
+ {
+ return $this->end;
+ }
+
+ /**
+ * Set the end date and time of the acknowledged
+ *
+ * @param int $end
+ *
+ * @return $this
+ */
+ public function setEnd($end)
+ {
+ $this->end = $end;
+ return $this;
+ }
+
+ /**
+ * Get the hash of the acknowledgement
+ *
+ * @return string
+ */
+ public function getHash()
+ {
+ if ($this->hash === null) {
+ $this->hash = md5($this->message);
+ }
+ return $this->hash;
+ }
+}
diff --git a/library/Icinga/Web/Announcement/AnnouncementCookie.php b/library/Icinga/Web/Announcement/AnnouncementCookie.php
new file mode 100644
index 0000000..6d23872
--- /dev/null
+++ b/library/Icinga/Web/Announcement/AnnouncementCookie.php
@@ -0,0 +1,138 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Announcement;
+
+use Icinga\Util\Json;
+use Icinga\Web\Cookie;
+
+/**
+ * Handle acknowledged announcements via cookie
+ */
+class AnnouncementCookie extends Cookie
+{
+ /**
+ * Array of hashes representing acknowledged announcements
+ *
+ * @var string[]
+ */
+ protected $acknowledged = array();
+
+ /**
+ * ETag of the last known announcements.ini
+ *
+ * @var string
+ */
+ protected $etag;
+
+ /**
+ * Timestamp of the next active acknowledgement, if any
+ *
+ * @var int|null
+ */
+ protected $nextActive;
+
+ /**
+ * AnnouncementCookie constructor
+ */
+ public function __construct()
+ {
+ parent::__construct('icingaweb2-announcements');
+ $this->setExpire(2147483648);
+ if (isset($_COOKIE['icingaweb2-announcements'])) {
+ $cookie = json_decode($_COOKIE['icingaweb2-announcements'], true);
+ if ($cookie !== null) {
+ if (isset($cookie['acknowledged'])) {
+ $this->setAcknowledged($cookie['acknowledged']);
+ }
+ if (isset($cookie['etag'])) {
+ $this->setEtag($cookie['etag']);
+ }
+ if (isset($cookie['next'])) {
+ $this->setNextActive($cookie['next']);
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the hashes of the acknowledged announcements
+ *
+ * @return string[]
+ */
+ public function getAcknowledged()
+ {
+ return $this->acknowledged;
+ }
+
+ /**
+ * Set the hashes of the acknowledged announcements
+ *
+ * @param string[] $acknowledged
+ *
+ * @return $this
+ */
+ public function setAcknowledged(array $acknowledged)
+ {
+ $this->acknowledged = $acknowledged;
+ return $this;
+ }
+
+ /**
+ * Get the ETag
+ *
+ * @return string
+ */
+ public function getEtag()
+ {
+ return $this->etag;
+ }
+
+ /**
+ * Set the ETag
+ *
+ * @param string $etag
+ *
+ * @return $this
+ */
+ public function setEtag($etag)
+ {
+ $this->etag = $etag;
+ return $this;
+ }
+
+ /**
+ * Get the timestamp of the next active announcement
+ *
+ * @return ?int
+ */
+ public function getNextActive()
+ {
+ return $this->nextActive;
+ }
+
+ /**
+ * Set the timestamp of the next active announcement
+ *
+ * @param ?int $nextActive
+ *
+ * @return $this
+ */
+ public function setNextActive(?int $nextActive)
+ {
+ $this->nextActive = $nextActive;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getValue()
+ {
+ return Json::encode(array(
+ 'acknowledged' => $this->getAcknowledged(),
+ 'etag' => $this->getEtag(),
+ 'next' => $this->getNextActive()
+ ));
+ }
+}
diff --git a/library/Icinga/Web/Announcement/AnnouncementIniRepository.php b/library/Icinga/Web/Announcement/AnnouncementIniRepository.php
new file mode 100644
index 0000000..d972a1d
--- /dev/null
+++ b/library/Icinga/Web/Announcement/AnnouncementIniRepository.php
@@ -0,0 +1,152 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Announcement;
+
+use DateTime;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterAnd;
+use Icinga\Data\SimpleQuery;
+use Icinga\Repository\IniRepository;
+use Icinga\Web\Announcement;
+
+/**
+ * A collection of announcements stored in an INI file
+ */
+class AnnouncementIniRepository extends IniRepository
+{
+ protected $queryColumns = array('announcement' => array('id', 'author', 'message', 'hash', 'start', 'end'));
+
+ protected $triggers = array('announcement');
+
+ protected $configs = array('announcement' => array(
+ 'name' => 'announcements',
+ 'keyColumn' => 'id'
+ ));
+
+ protected $conversionRules = array('announcement' => array(
+ 'start' => 'timestamp',
+ 'end' => 'timestamp'
+ ));
+
+ /**
+ * Get a DateTime's timestamp
+ *
+ * @param DateTime $datetime
+ *
+ * @return int|null
+ */
+ protected function persistTimestamp(DateTime $datetime)
+ {
+ return $datetime === null ? null : $datetime->getTimestamp();
+ }
+
+ /**
+ * Before-insert trigger (per row)
+ *
+ * @param ConfigObject $new The original data to insert
+ *
+ * @return ConfigObject The eventually modified data to insert
+ */
+ protected function onInsertAnnouncement(ConfigObject $new)
+ {
+ if (! isset($new->id)) {
+ $new->id = uniqid();
+ }
+
+ if (! isset($new->hash)) {
+ $announcement = new Announcement($new->toArray());
+ $new->hash = $announcement->getHash();
+ }
+
+ return $new;
+ }
+
+ /**
+ * Before-update trigger (per row)
+ *
+ * @param ConfigObject $old The original data as currently stored
+ * @param ConfigObject $new The original data to update
+ *
+ * @return ConfigObject The eventually modified data to update
+ */
+ protected function onUpdateAnnouncement(ConfigObject $old, ConfigObject $new)
+ {
+ if ($new->message !== $old->message) {
+ $announcement = new Announcement($new->toArray());
+ $new->hash = $announcement->getHash();
+ }
+
+ return $new;
+ }
+
+ /**
+ * Get the ETag of the announcements.ini file
+ *
+ * @return string
+ */
+ public function getEtag()
+ {
+ $file = $this->getDataSource('announcement')->getConfigFile();
+
+ if (@is_readable($file)) {
+ $mtime = filemtime($file);
+ $size = filesize($file);
+
+ return hash('crc32', $mtime . $size);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the query for all active announcements
+ *
+ * @return SimpleQuery
+ */
+ public function findActive()
+ {
+ $now = new DateTime();
+
+ $query = $this
+ ->select(array('hash', 'message', 'start'))
+ ->setFilter(new FilterAnd(array(
+ Filter::expression('start', '<=', $now),
+ Filter::expression('end', '>=', $now)
+ )))
+ ->order('start');
+
+ return $query;
+ }
+
+ /**
+ * Get the timestamp of the next active announcement
+ *
+ * @return int|null
+ */
+ public function findNextActive()
+ {
+ $now = new DateTime();
+
+ $query = $this
+ ->select(array('start', 'end'))
+ ->setFilter(Filter::matchAny(array(
+ Filter::expression('start', '>', $now), Filter::expression('end', '>', $now)
+ )));
+
+ $refresh = null;
+
+ foreach ($query as $row) {
+ $min = min($row->start, $row->end);
+
+ if ($refresh === null) {
+ $refresh = $min;
+ } else {
+ $refresh = min($refresh, $min);
+ }
+ }
+
+ return $refresh;
+ }
+}
diff --git a/library/Icinga/Web/ApplicationStateCookie.php b/library/Icinga/Web/ApplicationStateCookie.php
new file mode 100644
index 0000000..e40c17b
--- /dev/null
+++ b/library/Icinga/Web/ApplicationStateCookie.php
@@ -0,0 +1,74 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Logger;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Util\Json;
+
+/**
+ * Handle acknowledged application state messages via cookie
+ */
+class ApplicationStateCookie extends Cookie
+{
+ /** @var array */
+ protected $acknowledgedMessages = [];
+
+ public function __construct()
+ {
+ parent::__construct('icingaweb2-application-state');
+
+ $this->setExpire(2147483648);
+
+ if (isset($_COOKIE['icingaweb2-application-state'])) {
+ try {
+ $cookie = Json::decode($_COOKIE['icingaweb2-application-state'], true);
+ } catch (JsonDecodeException $e) {
+ Logger::error(
+ "Can't decode the application state cookie of user '%s'. An error occurred: %s",
+ Auth::getInstance()->getUser()->getUsername(),
+ $e
+ );
+
+ return;
+ }
+
+ if (isset($cookie['acknowledged-messages'])) {
+ $this->setAcknowledgedMessages($cookie['acknowledged-messages']);
+ }
+ }
+ }
+
+ /**
+ * Get the acknowledged messages
+ *
+ * @return array
+ */
+ public function getAcknowledgedMessages()
+ {
+ return $this->acknowledgedMessages;
+ }
+
+ /**
+ * Set the acknowledged messages
+ *
+ * @param array $acknowledged
+ *
+ * @return $this
+ */
+ public function setAcknowledgedMessages(array $acknowledged)
+ {
+ $this->acknowledgedMessages = $acknowledged;
+
+ return $this;
+ }
+
+ public function getValue()
+ {
+ return Json::encode([
+ 'acknowledged-messages' => $this->getAcknowledgedMessages()
+ ]);
+ }
+}
diff --git a/library/Icinga/Web/Controller.php b/library/Icinga/Web/Controller.php
new file mode 100644
index 0000000..008fbf6
--- /dev/null
+++ b/library/Icinga/Web/Controller.php
@@ -0,0 +1,264 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Data\Filterable;
+use Icinga\Data\Sortable;
+use Icinga\Data\QueryInterface;
+use Icinga\Exception\Http\HttpBadRequestException;
+use Icinga\Exception\Http\HttpNotFoundException;
+use Icinga\Web\Controller\ModuleActionController;
+use Icinga\Web\Widget\Limiter;
+use Icinga\Web\Widget\Paginator;
+use Icinga\Web\Widget\SortBox;
+
+/**
+ * This is the controller all modules should inherit from
+ * We will flip code with the ModuleActionController as soon as a couple
+ * of pending feature branches are merged back to the master.
+ *
+ * @property View $view
+ */
+class Controller extends ModuleActionController
+{
+ /**
+ * Cache for page size configured via user preferences
+ *
+ * @var false|int
+ */
+ protected $userPageSize;
+
+ /**
+ * @see ActionController::init
+ */
+ public function init()
+ {
+ parent::init();
+ $this->handleSortControlSubmit();
+ }
+
+ /**
+ * Check whether the sort control has been submitted and redirect using GET parameters
+ */
+ protected function handleSortControlSubmit()
+ {
+ $request = $this->getRequest();
+ if (! $request->isPost()) {
+ return;
+ }
+
+ if (($sort = $request->getPost('sort')) || ($direction = $request->getPost('dir'))) {
+ $url = Url::fromRequest();
+ if ($sort) {
+ $url->setParam('sort', $sort);
+ $url->remove('dir');
+ } else {
+ $url->setParam('dir', $direction);
+ }
+
+ $this->redirectNow($url);
+ }
+ }
+
+ /**
+ * Immediately respond w/ HTTP 400
+ *
+ * @param string $message Exception message or exception format string
+ * @param mixed ...$arg Format string argument
+ *
+ * @throws HttpBadRequestException
+ */
+ public function httpBadRequest($message)
+ {
+ throw HttpBadRequestException::create(func_get_args());
+ }
+
+ /**
+ * Immediately respond w/ HTTP 404
+ *
+ * @param string $message Exception message or exception format string
+ * @param mixed ...$arg Format string argument
+ *
+ * @throws HttpNotFoundException
+ */
+ public function httpNotFound($message)
+ {
+ throw HttpNotFoundException::create(func_get_args());
+ }
+
+ /**
+ * Render the given form using a simple view script
+ *
+ * @param Form $form
+ * @param string $tab
+ */
+ public function renderForm(Form $form, $tab)
+ {
+ $this->getTabs()->add(uniqid(), array(
+ 'active' => true,
+ 'label' => $tab,
+ 'url' => Url::fromRequest()
+ ));
+ $this->view->form = $form;
+ $this->render('simple-form', null, true);
+ }
+
+ /**
+ * Create a SortBox widget and apply its sort rules on the given query
+ *
+ * The widget is set on the `sortBox' view property only if the current view has not been requested as compact
+ *
+ * @param array $columns An array containing the sort columns, with the
+ * submit value as the key and the label as the value
+ * @param Sortable $query Query to apply the user chosen sort rules on
+ * @param array $defaults An array containing default sort directions for specific columns
+ *
+ * @return $this
+ */
+ protected function setupSortControl(array $columns, Sortable $query = null, array $defaults = null)
+ {
+ $request = $this->getRequest();
+ $sortBox = SortBox::create('sortbox-' . $request->getActionName(), $columns, $defaults);
+ $sortBox->setRequest($request);
+
+ if ($query) {
+ $sortBox->setQuery($query);
+ $sortBox->handleRequest($request);
+ }
+
+ if (! $this->view->compact) {
+ $this->view->sortBox = $sortBox;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Create a Limiter widget at the `limiter' view property
+ *
+ * In case the current view has been requested as compact this method does nothing.
+ *
+ * @param int $itemsPerPage Default number of items per page
+ *
+ * @return $this
+ */
+ protected function setupLimitControl($itemsPerPage = 25)
+ {
+ if (! $this->view->compact) {
+ $this->view->limiter = new Limiter();
+ $this->view->limiter->setDefaultLimit($this->getPageSize($itemsPerPage));
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the page size configured via user preferences or return the default value
+ *
+ * @param ?int $default
+ *
+ * @return int
+ */
+ protected function getPageSize($default)
+ {
+ if ($this->userPageSize === null) {
+ $user = $this->Auth()->getUser();
+ if ($user !== null) {
+ $pageSize = $user->getPreferences()->getValue('icingaweb', 'default_page_size');
+ $this->userPageSize = $pageSize ? (int) $pageSize : false;
+ } else {
+ $this->userPageSize = false;
+ }
+ }
+
+ return $this->userPageSize !== false ? $this->userPageSize : $default;
+ }
+
+ /**
+ * Apply the given page limit and number on the given query and setup a paginator for it
+ *
+ * The $itemsPerPage and $pageNumber parameters are only applied if not available in the current request.
+ * The paginator is set on the `paginator' view property only if the current view has not been requested as compact.
+ *
+ * @param QueryInterface $query The query to create a paginator for
+ * @param int $itemsPerPage Default number of items per page
+ * @param int $pageNumber Default page number
+ *
+ * @return $this
+ */
+ protected function setupPaginationControl(QueryInterface $query, $itemsPerPage = 25, $pageNumber = 0)
+ {
+ $request = $this->getRequest();
+ $limit = $request->getParam('limit', $this->getPageSize($itemsPerPage));
+ $page = $request->getParam('page', $pageNumber);
+ $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0);
+
+ if (! $this->view->compact) {
+ $paginator = new Paginator();
+ $paginator->setQuery($query);
+ $this->view->paginator = $paginator;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Create a FilterEditor widget and apply the user's chosen filter options on the given filterable
+ *
+ * The widget is set on the `filterEditor' view property only if the current view has not been requested as compact.
+ * The optional $filterColumns parameter should be an array of key-value pairs where the key is the name of the
+ * column and the value the label to show to the user. The optional $searchColumns parameter should be an array
+ * of column names to be used to handle quick searches.
+ *
+ * If the given filterable is an instance of Icinga\Data\FilterColumns, $filterable->getFilterColumns() and
+ * $filterable->getSearchColumns() is called to provide the respective columns if $filterColumns or $searchColumns
+ * is not given.
+ *
+ * @param Filterable $filterable The filterable to create a filter editor for
+ * @param array $filterColumns The filter columns to offer to the user
+ * @param array $searchColumns The search columns to utilize for quick searches
+ * @param array $preserveParams The url parameters to preserve
+ *
+ * @return $this
+ *
+ * @todo Preserving and ignoring parameters should be configurable (another two method params? property magic?)
+ */
+ protected function setupFilterControl(
+ Filterable $filterable,
+ array $filterColumns = null,
+ array $searchColumns = null,
+ array $preserveParams = null
+ ) {
+ $defaultPreservedParams = array(
+ 'limit', // setupPaginationControl()
+ 'sort', // setupSortControl()
+ 'dir', // setupSortControl()
+ 'backend', // Framework
+ 'showCompact', // Framework
+ '_dev' // Framework
+ );
+
+ $editor = Widget::create('filterEditor');
+ /** @var \Icinga\Web\Widget\FilterEditor $editor */
+ call_user_func_array(
+ array($editor, 'preserveParams'),
+ array_merge($defaultPreservedParams, $preserveParams ?: array())
+ );
+
+ $editor
+ ->setQuery($filterable)
+ ->ignoreParams('page') // setupPaginationControl()
+ ->setColumns($filterColumns)
+ ->setSearchColumns($searchColumns)
+ ->handleRequest($this->getRequest());
+
+ if ($this->view->compact) {
+ $editor->setVisible(false);
+ }
+
+ $this->view->filterEditor = $editor;
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Web/Controller/ActionController.php b/library/Icinga/Web/Controller/ActionController.php
new file mode 100644
index 0000000..2e36d7d
--- /dev/null
+++ b/library/Icinga/Web/Controller/ActionController.php
@@ -0,0 +1,617 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Controller;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Common\PdfExport;
+use Icinga\File\Pdf;
+use Icinga\Util\Csp;
+use Icinga\Web\View;
+use ipl\I18n\Translation;
+use Zend_Controller_Action;
+use Zend_Controller_Action_HelperBroker;
+use Zend_Controller_Request_Abstract;
+use Zend_Controller_Response_Abstract;
+use Icinga\Application\Benchmark;
+use Icinga\Application\Config;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\Http\HttpMethodNotAllowedException;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Forms\AutoRefreshForm;
+use Icinga\Security\SecurityException;
+use Icinga\Web\Session;
+use Icinga\Web\Url;
+use Icinga\Web\UrlParams;
+use Icinga\Web\Widget\Tabs;
+use Icinga\Web\Window;
+
+/**
+ * Base class for all core action controllers
+ *
+ * All Icinga Web core controllers should extend this class
+ *
+ * @method \Icinga\Web\Request getRequest() {
+ * {@inheritdoc}
+ * @return \Icinga\Web\Request
+ * }
+ *
+ * @method \Icinga\Web\Response getResponse() {
+ * {@inheritdoc}
+ * @return \Icinga\Web\Response
+ * }
+ */
+class ActionController extends Zend_Controller_Action
+{
+ use Translation;
+ use PdfExport {
+ sendAsPdf as private newSendAsPdf;
+ }
+
+ /**
+ * The login route to use when requiring authentication
+ */
+ const LOGIN_ROUTE = 'authentication/login';
+
+ /**
+ * The default page title to use
+ */
+ const DEFAULT_TITLE = 'Icinga Web';
+
+ /**
+ * Whether the controller requires the user to be authenticated
+ *
+ * @var bool
+ */
+ protected $requiresAuthentication = true;
+
+ /**
+ * The current module's name
+ *
+ * @var string
+ */
+ protected $moduleName;
+
+ /**
+ * A page's automatic refresh interval
+ *
+ * The initial value will not be subject to a user's preferences.
+ *
+ * @var int
+ */
+ protected $autorefreshInterval;
+
+ protected $reloadCss = false;
+
+ protected $window;
+
+ protected $rerenderLayout = false;
+
+ /**
+ * The inline layout (inside columns) to use
+ *
+ * @var string
+ */
+ protected $inlineLayout = 'inline';
+
+ /**
+ * The inner layout (inside the body) to use
+ *
+ * @var string
+ */
+ protected $innerLayout = 'body';
+
+ /**
+ * Authentication manager
+ *
+ * @var Auth|null
+ */
+ protected $auth;
+
+ /**
+ * URL parameters
+ *
+ * @var UrlParams
+ */
+ protected $params;
+
+ /**
+ * @var View
+ */
+ public $view;
+
+ /**
+ * The constructor starts benchmarking, loads the configuration and sets
+ * other useful controller properties
+ *
+ * @param Zend_Controller_Request_Abstract $request
+ * @param Zend_Controller_Response_Abstract $response
+ * @param array $invokeArgs Any additional invocation arguments
+ */
+ public function __construct(
+ Zend_Controller_Request_Abstract $request,
+ Zend_Controller_Response_Abstract $response,
+ array $invokeArgs = array()
+ ) {
+ /** @var \Icinga\Web\Request $request */
+ /** @var \Icinga\Web\Response $response */
+ $this->params = UrlParams::fromQueryString();
+
+ $this->setRequest($request)
+ ->setResponse($response)
+ ->_setInvokeArgs($invokeArgs);
+ $this->_helper = new Zend_Controller_Action_HelperBroker($this);
+
+ $moduleName = $this->getModuleName();
+ $this->view->defaultTitle = static::DEFAULT_TITLE;
+ $this->translationDomain = $moduleName !== 'default' ? $moduleName : 'icinga';
+ $this->view->translationDomain = $this->translationDomain;
+ $this->_helper->layout()->isIframe = $request->getUrl()->shift('isIframe');
+ $this->_helper->layout()->showFullscreen = $request->getUrl()->shift('showFullscreen');
+ $this->_helper->layout()->moduleName = $moduleName;
+
+ $this->view->compact = false;
+ if ($request->getUrl()->getParam('view') === 'compact') {
+ $request->getUrl()->remove('view');
+ $this->view->compact = true;
+ }
+ if ($request->getUrl()->shift('showCompact')) {
+ $this->view->compact = true;
+ }
+ $this->rerenderLayout = $request->getUrl()->shift('renderLayout');
+ if ($request->getUrl()->shift('_disableLayout')) {
+ $this->_helper->layout()->disableLayout();
+ }
+
+ // $auth->authenticate($request, $response, $this->requiresLogin());
+ if ($this->requiresLogin()) {
+ if (! $request->isXmlHttpRequest() && $request->isApiRequest()) {
+ Auth::getInstance()->challengeHttp();
+ }
+ $this->redirectToLogin(Url::fromRequest());
+ }
+
+ if (! $this->isXhr() && Config::app()->get('security', 'use_strict_csp', false)) {
+ Csp::createNonce();
+ }
+
+ $this->view->tabs = new Tabs();
+ $this->prepareInit();
+ $this->init();
+ }
+
+ /**
+ * Prepare controller initialization
+ *
+ * As it should not be required for controllers to call the parent's init() method, base controllers should use
+ * prepareInit() in order to prepare the controller initialization.
+ *
+ * @see \Zend_Controller_Action::init() For the controller initialization method.
+ */
+ protected function prepareInit()
+ {
+ }
+
+ /**
+ * Get the authentication manager
+ *
+ * @return Auth
+ */
+ public function Auth()
+ {
+ if ($this->auth === null) {
+ $this->auth = Auth::getInstance();
+ }
+ return $this->auth;
+ }
+
+ /**
+ * Whether the current user has the given permission
+ *
+ * @param string $permission Name of the permission
+ *
+ * @return bool
+ */
+ public function hasPermission($permission)
+ {
+ return $this->Auth()->hasPermission($permission);
+ }
+
+ /**
+ * Assert that the current user has the given permission
+ *
+ * @param string $permission Name of the permission
+ *
+ * @throws SecurityException If the current user lacks the given permission
+ */
+ public function assertPermission($permission)
+ {
+ if (! $this->Auth()->hasPermission($permission)) {
+ throw new SecurityException('No permission for %s', $permission);
+ }
+ }
+
+ /**
+ * Return the current module's name
+ *
+ * @return string
+ */
+ public function getModuleName()
+ {
+ if ($this->moduleName === null) {
+ $this->moduleName = $this->getRequest()->getModuleName();
+ }
+
+ return $this->moduleName;
+ }
+
+ public function Config($file = null)
+ {
+ if ($file === null) {
+ return Config::app();
+ } else {
+ return Config::app($file);
+ }
+ }
+
+ public function Window()
+ {
+ if ($this->window === null) {
+ $this->window = Window::getInstance();
+ }
+
+ return $this->window;
+ }
+
+ protected function reloadCss()
+ {
+ $this->reloadCss = true;
+ return $this;
+ }
+
+ /**
+ * Respond with HTTP 405 if the current request's method is not one of the given methods
+ *
+ * @param string $httpMethod Unlimited number of allowed HTTP methods
+ *
+ * @throws HttpMethodNotAllowedException If the request method is not one of the given methods
+ */
+ public function assertHttpMethod($httpMethod)
+ {
+ $httpMethods = array_flip(array_map('strtoupper', func_get_args()));
+ if (! isset($httpMethods[$this->getRequest()->getMethod()])) {
+ $e = new HttpMethodNotAllowedException($this->translate('Method Not Allowed'));
+ $e->setAllowedMethods(implode(', ', array_keys($httpMethods)));
+ throw $e;
+ }
+ }
+
+ /**
+ * Return restriction information for an eventually authenticated user
+ *
+ * @param string $name Restriction name
+ *
+ * @return array
+ */
+ public function getRestrictions($name)
+ {
+ return $this->Auth()->getRestrictions($name);
+ }
+
+ /**
+ * Check whether the controller requires a login. That is when the controller requires authentication and the
+ * user is currently not authenticated
+ *
+ * @return bool
+ */
+ protected function requiresLogin()
+ {
+ if (! $this->requiresAuthentication) {
+ return false;
+ }
+
+ return ! $this->Auth()->isAuthenticated();
+ }
+
+ /**
+ * Return the tabs
+ *
+ * @return Tabs
+ */
+ public function getTabs()
+ {
+ return $this->view->tabs;
+ }
+
+ protected function ignoreXhrBody()
+ {
+ if ($this->isXhr()) {
+ $this->getResponse()->setHeader('X-Icinga-Container', 'ignore');
+ }
+ }
+
+ /**
+ * Set the interval (in seconds) at which the page should automatically refresh
+ *
+ * This may be adjusted based on the user's preferences. The result could be a
+ * lower or higher rate of the page's automatic refresh. If this is not desired,
+ * the only way to bypass this is to initialize the {@see ActionController::$autorefreshInterval}
+ * property or to set the `autorefreshInterval` property of the layout directly.
+ *
+ * @param int $interval
+ *
+ * @return $this
+ */
+ public function setAutorefreshInterval($interval)
+ {
+ if (! is_int($interval) || $interval < 1) {
+ throw new ProgrammingError(
+ 'Setting autorefresh interval smaller than 1 second is not allowed'
+ );
+ }
+
+ $user = $this->getRequest()->getUser();
+ if ($user !== null) {
+ $speed = (float) $user->getPreferences()->getValue('icingaweb', 'auto_refresh_speed', 1.0);
+ $interval = max(round($interval * $speed), min($interval, 5));
+ }
+
+ $this->autorefreshInterval = $interval;
+
+ return $this;
+ }
+
+ public function disableAutoRefresh()
+ {
+ $this->autorefreshInterval = null;
+
+ return $this;
+ }
+
+ /**
+ * Redirect to login
+ *
+ * XHR will always redirect to __SELF__ if an URL to redirect to after successful login is set. __SELF__ instructs
+ * JavaScript to redirect to the current window's URL if it's an auto-refresh request or to redirect to the URL
+ * which required login if it's not an auto-refreshing one.
+ *
+ * XHR will respond with HTTP status code 403 Forbidden.
+ *
+ * @param Url|string $redirect URL to redirect to after successful login
+ */
+ protected function redirectToLogin($redirect = null)
+ {
+ $login = Url::fromPath(static::LOGIN_ROUTE);
+ if ($this->isXhr()) {
+ if ($redirect !== null) {
+ $login->setParam('redirect', '__SELF__');
+ }
+
+ $this->_response->setHttpResponseCode(403);
+ } elseif ($redirect !== null) {
+ if (! $redirect instanceof Url) {
+ $redirect = Url::fromPath($redirect);
+ }
+
+ if (($relativeUrl = $redirect->getRelativeUrl())) {
+ $login->setParam('redirect', $relativeUrl);
+ }
+ }
+
+ $this->getResponse()->setReloadWindow(true);
+ $this->redirectNow($login);
+ }
+
+ protected function rerenderLayout()
+ {
+ $this->rerenderLayout = true;
+ return $this;
+ }
+
+ public function isXhr()
+ {
+ return $this->getRequest()->isXmlHttpRequest();
+ }
+
+ /**
+ * Issue a redirect that's performed with XHR by the client
+ *
+ * @param Url|string $url
+ *
+ * @return never
+ */
+ protected function redirectXhr($url)
+ {
+ $response = $this->getResponse();
+
+ if ($this->reloadCss) {
+ $response->setReloadCss(true);
+ }
+
+ if ($this->rerenderLayout) {
+ $response->setRerenderLayout(true);
+ }
+
+ $response->redirectAndExit($url);
+ }
+
+ /**
+ * Issue a redirect that's performed as a native HTTP request by the client
+ *
+ * This will effectively reload the window
+ *
+ * @param Url|string $url
+ *
+ * @return never
+ */
+ protected function redirectHttp($url)
+ {
+ if ($this->isXhr()) {
+ $this->getResponse()->setHeader('X-Icinga-Redirect-Http', 'yes');
+ }
+
+ $this->getResponse()->redirectAndExit($url);
+ }
+
+ /**
+ * Redirect to a specific url, updating the browsers URL field
+ *
+ * @param Url|string $url The target to redirect to
+ *
+ * @return never
+ **/
+ public function redirectNow($url)
+ {
+ if ($this->isXhr()) {
+ $this->redirectXhr($url);
+ } else {
+ $this->redirectHttp($url);
+ }
+ }
+
+ /**
+ * @see Zend_Controller_Action::preDispatch()
+ */
+ public function preDispatch()
+ {
+ $form = new AutoRefreshForm();
+ if (! $this->getRequest()->isApiRequest()) {
+ $form->handleRequest();
+ }
+ $this->_helper->layout()->autoRefreshForm = $form;
+ }
+
+ /**
+ * Detect whether the current request requires changes in the layout and apply them before rendering
+ *
+ * @see Zend_Controller_Action::postDispatch()
+ */
+ public function postDispatch()
+ {
+ Benchmark::measure('Action::postDispatch()');
+
+ $req = $this->getRequest();
+ $layout = $this->_helper->layout();
+ $layout->innerLayout = $this->innerLayout;
+ $layout->inlineLayout = $this->inlineLayout;
+
+ if ($user = $req->getUser()) {
+ if ((bool) $user->getPreferences()->getValue('icingaweb', 'show_benchmark', false)) {
+ if ($this->_helper->layout()->isEnabled()) {
+ $layout->benchmark = $this->renderBenchmark();
+ }
+ }
+
+ if (! (bool) $user->getPreferences()->getValue('icingaweb', 'auto_refresh', true)) {
+ $this->disableAutoRefresh();
+ }
+ }
+
+ if ($this->autorefreshInterval !== null) {
+ $layout->autorefreshInterval = $this->autorefreshInterval;
+ }
+
+ if ($req->getParam('error_handler') === null && $req->getParam('format') === 'pdf') {
+ $this->sendAsPdf();
+ $this->shutdownSession();
+ exit;
+ }
+
+ if ($this->isXhr()) {
+ $this->postDispatchXhr();
+ }
+
+ $this->shutdownSession();
+ }
+
+ protected function postDispatchXhr()
+ {
+ $resp = $this->getResponse();
+
+ if ($this->reloadCss) {
+ $resp->setReloadCss(true);
+ }
+
+ if ($this->view->title) {
+ if (preg_match('~[\r\n]~', $this->view->title)) {
+ // TODO: Innocent exception and error log for hack attempts
+ throw new IcingaException('No way, guy');
+ }
+ $resp->setHeader(
+ 'X-Icinga-Title',
+ rawurlencode($this->view->title . ' :: ' . $this->view->defaultTitle),
+ true
+ );
+ } else {
+ $resp->setHeader('X-Icinga-Title', rawurlencode($this->view->defaultTitle), true);
+ }
+
+ $layout = $this->_helper->layout();
+ if ($this->rerenderLayout) {
+ $layout->setLayout($this->innerLayout);
+ $resp->setRerenderLayout(true);
+ } else {
+ // The layout may be disabled and there's no indication that the layout is explicitly desired,
+ // that's why we're passing false as second parameter to setLayout
+ $layout->setLayout($this->inlineLayout, false);
+ }
+
+ if ($this->autorefreshInterval !== null) {
+ $resp->setAutoRefreshInterval($this->autorefreshInterval);
+ }
+ }
+
+ protected function sendAsPdf()
+ {
+ if (Module::exists('pdfexport')) {
+ $this->newSendAsPdf();
+ } else {
+ $pdf = new Pdf();
+ $pdf->renderControllerAction($this);
+ }
+ }
+
+ protected function shutdownSession()
+ {
+ $session = Session::getSession();
+ if ($session->hasChanged()) {
+ $session->write();
+ }
+ }
+
+ /**
+ * Render the benchmark
+ *
+ * @return string Benchmark HTML
+ */
+ protected function renderBenchmark()
+ {
+ $this->_helper->viewRenderer->postDispatch();
+ Benchmark::measure('Response ready');
+ return Benchmark::renderToHtml();
+ }
+
+ /**
+ * Try to call compatible methods from older zend versions
+ *
+ * Methods like getParam and redirect are _getParam/_redirect in older Zend versions (which reside for example
+ * in Debian Wheezy). Using those methods without the "_" causes the application to fail on those platforms, but
+ * using the version with "_" forces us to use deprecated code. So we try to catch this issue by looking for methods
+ * with the same name, but with a "_" prefix prepended.
+ *
+ * @param string $name The method name to check
+ * @param mixed $params The method parameters
+ * @return mixed Anything the method returns
+ */
+ public function __call($name, $params)
+ {
+ $deprecatedMethod = '_' . $name;
+
+ if (method_exists($this, $deprecatedMethod)) {
+ return call_user_func_array(array($this, $deprecatedMethod), $params);
+ }
+
+ parent::__call($name, $params);
+ }
+}
diff --git a/library/Icinga/Web/Controller/AuthBackendController.php b/library/Icinga/Web/Controller/AuthBackendController.php
new file mode 100644
index 0000000..12f8b72
--- /dev/null
+++ b/library/Icinga/Web/Controller/AuthBackendController.php
@@ -0,0 +1,151 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Controller;
+
+use ipl\Web\Compat\CompatController;
+use Zend_Controller_Action_Exception;
+use Icinga\Application\Config;
+use Icinga\Authentication\User\UserBackend;
+use Icinga\Authentication\User\UserBackendInterface;
+use Icinga\Authentication\UserGroup\UserGroupBackend;
+use Icinga\Authentication\UserGroup\UserGroupBackendInterface;
+
+/**
+ * Base class for authentication backend controllers
+ */
+class AuthBackendController extends CompatController
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->tabs->disableLegacyExtensions();
+ }
+
+ /**
+ * Redirect to this controller's list action
+ */
+ public function indexAction()
+ {
+ $this->redirectNow($this->getRequest()->getControllerName() . '/list');
+ }
+
+ /**
+ * Return all user backends implementing the given interface
+ *
+ * @param string $interface The class path of the interface, or null if no interface check should be made
+ *
+ * @return array
+ */
+ protected function loadUserBackends($interface = null)
+ {
+ $backends = array();
+ foreach (Config::app('authentication') as $backendName => $backendConfig) {
+ $candidate = UserBackend::create($backendName, $backendConfig);
+ if (! $interface || $candidate instanceof $interface) {
+ $backends[] = $candidate;
+ }
+ }
+
+ return $backends;
+ }
+
+ /**
+ * Return the given user backend or the first match in order
+ *
+ * @param string $name The name of the backend, or null in case the first match should be returned
+ * @param string $interface The interface the backend should implement, no interface check if null
+ *
+ * @return UserBackendInterface
+ *
+ * @throws Zend_Controller_Action_Exception In case the given backend name is invalid
+ */
+ protected function getUserBackend($name = null, $interface = 'Icinga\Data\Selectable')
+ {
+ $backend = null;
+ if ($name !== null) {
+ $config = Config::app('authentication');
+ if (! $config->hasSection($name)) {
+ $this->httpNotFound(sprintf($this->translate('Authentication backend "%s" not found'), $name));
+ } else {
+ $backend = UserBackend::create($name, $config->getSection($name));
+ if ($interface && !$backend instanceof $interface) {
+ $interfaceParts = explode('\\', strtolower($interface));
+ throw new Zend_Controller_Action_Exception(
+ sprintf(
+ $this->translate('Authentication backend "%s" is not %s'),
+ $name,
+ array_pop($interfaceParts)
+ ),
+ 400
+ );
+ }
+ }
+ } else {
+ $backends = $this->loadUserBackends($interface);
+ $backend = array_shift($backends);
+ }
+
+ return $backend;
+ }
+
+ /**
+ * Return all user group backends implementing the given interface
+ *
+ * @param string $interface The class path of the interface, or null if no interface check should be made
+ *
+ * @return array
+ */
+ protected function loadUserGroupBackends($interface = null)
+ {
+ $backends = array();
+ foreach (Config::app('groups') as $backendName => $backendConfig) {
+ $candidate = UserGroupBackend::create($backendName, $backendConfig);
+ if (! $interface || $candidate instanceof $interface) {
+ $backends[] = $candidate;
+ }
+ }
+
+ return $backends;
+ }
+
+ /**
+ * Return the given user group backend or the first match in order
+ *
+ * @param string $name The name of the backend, or null in case the first match should be returned
+ * @param string $interface The interface the backend should implement, no interface check if null
+ *
+ * @return UserGroupBackendInterface
+ *
+ * @throws Zend_Controller_Action_Exception In case the given backend name is invalid
+ */
+ protected function getUserGroupBackend($name = null, $interface = 'Icinga\Data\Selectable')
+ {
+ $backend = null;
+ if ($name !== null) {
+ $config = Config::app('groups');
+ if (! $config->hasSection($name)) {
+ $this->httpNotFound(sprintf($this->translate('User group backend "%s" not found'), $name));
+ } else {
+ $backend = UserGroupBackend::create($name, $config->getSection($name));
+ if ($interface && !$backend instanceof $interface) {
+ $interfaceParts = explode('\\', strtolower($interface));
+ throw new Zend_Controller_Action_Exception(
+ sprintf(
+ $this->translate('User group backend "%s" is not %s'),
+ $name,
+ array_pop($interfaceParts)
+ ),
+ 400
+ );
+ }
+ }
+ } else {
+ $backends = $this->loadUserGroupBackends($interface);
+ $backend = array_shift($backends);
+ }
+
+ return $backend;
+ }
+}
diff --git a/library/Icinga/Web/Controller/BasePreferenceController.php b/library/Icinga/Web/Controller/BasePreferenceController.php
new file mode 100644
index 0000000..8f2da8f
--- /dev/null
+++ b/library/Icinga/Web/Controller/BasePreferenceController.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Controller;
+
+/**
+ * Base class for Preference Controllers
+ *
+ * Module preferences use this class to make sure they are automatically
+ * added to the general preferences dialog. If you create a subclass of
+ * BasePreferenceController and overwrite @see init(), make sure you call
+ * parent::init(), otherwise you won't have the $tabs property in your view.
+ *
+ */
+class BasePreferenceController extends ActionController
+{
+ /**
+ * Return an array of tabs provided by this preference controller.
+ *
+ * Those tabs will automatically be added to the application's preference dialog
+ *
+ * @return array
+ */
+ public static function createProvidedTabs()
+ {
+ return array();
+ }
+
+ /**
+ * Initialize the controller and collect all tabs for it from the application and its modules
+ *
+ * @see ActionController::init()
+ */
+ public function init()
+ {
+ parent::init();
+ $this->view->tabs = ControllerTabCollector::collectControllerTabs('PreferenceController');
+ }
+}
diff --git a/library/Icinga/Web/Controller/ControllerTabCollector.php b/library/Icinga/Web/Controller/ControllerTabCollector.php
new file mode 100644
index 0000000..b452a20
--- /dev/null
+++ b/library/Icinga/Web/Controller/ControllerTabCollector.php
@@ -0,0 +1,97 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Controller;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Application\Icinga;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Static helper class that collects tabs provided by the 'createProvidedTabs' method of controllers
+ */
+class ControllerTabCollector
+{
+ /**
+ * Scan all controllers with given name in the application and (loaded) module folders and collects their provided
+ * tabs
+ *
+ * @param string $controllerName The name of the controllers to use for tab collection
+ *
+ * @return Tabs A {@link Tabs} instance containing the application tabs first followed by the
+ * tabs provided from the modules
+ */
+ public static function collectControllerTabs($controllerName)
+ {
+ $controller = '\Icinga\\' . Dispatcher::CONTROLLER_NAMESPACE . '\\' . $controllerName;
+ $applicationTabs = $controller::createProvidedTabs();
+ $moduleTabs = self::collectModuleTabs($controllerName);
+
+ $tabs = new Tabs();
+ foreach ($applicationTabs as $name => $tab) {
+ $tabs->add($name, $tab);
+ }
+
+ foreach ($moduleTabs as $name => $tab) {
+ // Don't overwrite application tabs if the module wants to
+ if ($tabs->has($name)) {
+ continue;
+ }
+ $tabs->add($name, $tab);
+ }
+ return $tabs;
+ }
+
+ /**
+ * Collect module tabs for all modules containing the given controller
+ *
+ * @param string $controller The controller name to use for tab collection
+ *
+ * @return array An array of Tabs objects or arrays containing Tab descriptions
+ */
+ private static function collectModuleTabs($controller)
+ {
+ $moduleManager = Icinga::app()->getModuleManager();
+ $modules = $moduleManager->listEnabledModules();
+ $tabs = array();
+ foreach ($modules as $module) {
+ $tabs += self::createModuleConfigurationTabs($controller, $moduleManager->getModule($module));
+ }
+
+ return $tabs;
+ }
+
+ /**
+ * Collects the tabs from the createProvidedTabs() method in the configuration controller
+ *
+ * If the module doesn't have the given controller or createProvidedTabs method in the controller an empty array
+ * will be returned
+ *
+ * @param string $controllerName The name of the controller that provides tabs via createProvidedTabs
+ * @param Module $module The module instance that provides the controller
+ *
+ * @return array
+ */
+ private static function createModuleConfigurationTabs($controllerName, Module $module)
+ {
+ // TODO(el): Only works for controllers w/o namepsace: https://dev.icinga.com/issues/4149
+ $controllerDir = $module->getControllerDir();
+ $name = $module->getName();
+
+ $controllerDir = $controllerDir . '/' . $controllerName . '.php';
+ $controllerName = ucfirst($name) . '_' . $controllerName;
+
+ if (is_readable($controllerDir)) {
+ require_once(realpath($controllerDir));
+ if (! method_exists($controllerName, 'createProvidedTabs')) {
+ return array();
+ }
+ $tab = $controllerName::createProvidedTabs();
+ if (! is_array($tab)) {
+ $tab = array($name => $tab);
+ }
+ return $tab;
+ }
+ return array();
+ }
+}
diff --git a/library/Icinga/Web/Controller/Dispatcher.php b/library/Icinga/Web/Controller/Dispatcher.php
new file mode 100644
index 0000000..e2dfb80
--- /dev/null
+++ b/library/Icinga/Web/Controller/Dispatcher.php
@@ -0,0 +1,93 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Controller;
+
+use Exception;
+use Icinga\Util\StringHelper;
+use Zend_Controller_Action;
+use Zend_Controller_Action_Interface;
+use Zend_Controller_Dispatcher_Exception;
+use Zend_Controller_Dispatcher_Standard;
+use Zend_Controller_Request_Abstract;
+use Zend_Controller_Response_Abstract;
+
+/**
+ * Dispatcher supporting Zend-style and namespaced controllers
+ *
+ * Does not support a namespaced default controller in combination w/ the Zend parameter useDefaultControllerAlways.
+ */
+class Dispatcher extends Zend_Controller_Dispatcher_Standard
+{
+ /**
+ * Controller namespace
+ *
+ * @var string
+ */
+ const CONTROLLER_NAMESPACE = 'Controllers';
+
+ /**
+ * Dispatch request to a controller and action
+ *
+ * @param Zend_Controller_Request_Abstract $request
+ * @param Zend_Controller_Response_Abstract $response
+ *
+ * @throws Zend_Controller_Dispatcher_Exception If the controller is not an instance of
+ * Zend_Controller_Action_Interface
+ * @throws Exception If dispatching the request fails
+ */
+ public function dispatch(Zend_Controller_Request_Abstract $request, Zend_Controller_Response_Abstract $response)
+ {
+ $this->setResponse($response);
+ $controllerName = $request->getControllerName();
+ if (! $controllerName) {
+ parent::dispatch($request, $response);
+ return;
+ }
+ $controllerName = StringHelper::cname($controllerName, '-') . 'Controller';
+ $moduleName = $request->getModuleName();
+ if ($moduleName === null || $moduleName === $this->_defaultModule) {
+ $controllerClass = 'Icinga\\' . self::CONTROLLER_NAMESPACE . '\\' . $controllerName;
+ } else {
+ $controllerClass = 'Icinga\\Module\\' . ucfirst($moduleName) . '\\' . self::CONTROLLER_NAMESPACE . '\\'
+ . $controllerName;
+ }
+ if (! class_exists($controllerClass)) {
+ parent::dispatch($request, $response);
+ return;
+ }
+ $controller = new $controllerClass($request, $response, $this->getParams());
+ if (! $controller instanceof Zend_Controller_Action
+ && ! $controller instanceof Zend_Controller_Action_Interface
+ ) {
+ throw new Zend_Controller_Dispatcher_Exception(
+ 'Controller "' . $controllerClass . '" is not an instance of Zend_Controller_Action_Interface'
+ );
+ }
+ $action = $this->getActionMethod($request);
+ $request->setDispatched(true);
+ // Buffer output by default
+ $disableOb = $this->getParam('disableOutputBuffering');
+ $obLevel = ob_get_level();
+ if (empty($disableOb)) {
+ ob_start();
+ }
+ try {
+ $controller->dispatch($action);
+ } catch (Exception $e) {
+ // Clean output buffer on error
+ $curObLevel = ob_get_level();
+ if ($curObLevel > $obLevel) {
+ do {
+ ob_get_clean();
+ $curObLevel = ob_get_level();
+ } while ($curObLevel > $obLevel);
+ }
+ throw $e;
+ }
+ if (empty($disableOb)) {
+ $content = ob_get_clean();
+ $response->appendBody($content);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Controller/ModuleActionController.php b/library/Icinga/Web/Controller/ModuleActionController.php
new file mode 100644
index 0000000..ad66264
--- /dev/null
+++ b/library/Icinga/Web/Controller/ModuleActionController.php
@@ -0,0 +1,80 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Controller;
+
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Manager;
+use Icinga\Application\Modules\Module;
+
+/**
+ * Base class for module action controllers
+ */
+class ModuleActionController extends ActionController
+{
+ protected $config;
+
+ protected $configs = array();
+
+ protected $module;
+
+ /**
+ * (non-PHPDoc)
+ * @see \Icinga\Web\Controller\ActionController For the method documentation.
+ */
+ protected function prepareInit()
+ {
+ $this->moduleInit();
+ if (($this->Auth()->isAuthenticated() || $this->requiresLogin())
+ && $this->getFrontController()->getDefaultModule() !== $this->getModuleName()) {
+ $this->assertPermission(Manager::MODULE_PERMISSION_NS . $this->getModuleName());
+ }
+ }
+
+ /**
+ * Prepare module action controller initialization
+ */
+ protected function moduleInit()
+ {
+ }
+
+ public function Config($file = null)
+ {
+ if ($file === null) {
+ if ($this->config === null) {
+ $this->config = Config::module($this->getModuleName());
+ }
+ return $this->config;
+ } else {
+ if (! array_key_exists($file, $this->configs)) {
+ $this->configs[$file] = Config::module($this->getModuleName(), $file);
+ }
+ return $this->configs[$file];
+ }
+ }
+
+ /**
+ * Return this controller's module
+ *
+ * @return Module
+ */
+ public function Module()
+ {
+ if ($this->module === null) {
+ $this->module = Icinga::app()->getModuleManager()->getModule($this->getModuleName());
+ }
+
+ return $this->module;
+ }
+
+ /**
+ * (non-PHPDoc)
+ * @see \Icinga\Web\Controller\ActionController::postDispatchXhr() For the method documentation.
+ */
+ public function postDispatchXhr()
+ {
+ parent::postDispatchXhr();
+ $this->getResponse()->setHeader('X-Icinga-Module', $this->getModuleName(), true);
+ }
+}
diff --git a/library/Icinga/Web/Controller/StaticController.php b/library/Icinga/Web/Controller/StaticController.php
new file mode 100644
index 0000000..f5ce163
--- /dev/null
+++ b/library/Icinga/Web/Controller/StaticController.php
@@ -0,0 +1,87 @@
+<?php
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Controller;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\Request;
+
+class StaticController
+{
+ /**
+ * Handle incoming request
+ *
+ * @param Request $request
+ *
+ * @returns void
+ */
+ public function handle(Request $request)
+ {
+ $app = Icinga::app();
+
+ // +4 because strlen('/lib') === 4
+ $assetPath = ltrim(substr($request->getRequestUri(), strlen($request->getBaseUrl()) + 4), '/');
+
+ $library = null;
+ foreach ($app->getLibraries() as $candidate) {
+ if (substr($assetPath, 0, strlen($candidate->getName())) === $candidate->getName()) {
+ $library = $candidate;
+ $assetPath = ltrim(substr($assetPath, strlen($candidate->getName())), '/');
+ break;
+ }
+ }
+
+ if ($library === null) {
+ $app->getResponse()
+ ->setHttpResponseCode(404);
+
+ return;
+ }
+
+ $assetRoot = $library->getStaticAssetPath();
+ if (empty($assetRoot)) {
+ $app->getResponse()
+ ->setHttpResponseCode(404);
+
+ return;
+ }
+
+ $filePath = $assetRoot . DIRECTORY_SEPARATOR . $assetPath;
+ $dirPath = realpath(dirname($filePath)); // dirname, because the file may be a link
+
+ if ($dirPath === false
+ || substr($dirPath, 0, strlen($assetRoot)) !== $assetRoot
+ || ! is_file($filePath)
+ ) {
+ $app->getResponse()
+ ->setHttpResponseCode(404);
+
+ return;
+ }
+
+ $fileStat = stat($filePath);
+ $eTag = sprintf(
+ '%x-%x-%x',
+ $fileStat['ino'],
+ $fileStat['size'],
+ (float) str_pad($fileStat['mtime'], 16, '0')
+ );
+
+ $app->getResponse()->setHeader(
+ 'Cache-Control',
+ 'public, max-age=1814400, stale-while-revalidate=604800',
+ true
+ );
+
+ if ($request->getServer('HTTP_IF_NONE_MATCH') === $eTag) {
+ $app->getResponse()
+ ->setHttpResponseCode(304);
+ } else {
+ $app->getResponse()
+ ->setHeader('ETag', $eTag)
+ ->setHeader('Content-Type', mime_content_type($filePath), true)
+ ->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $fileStat['mtime']) . ' GMT')
+ ->setBody(file_get_contents($filePath));
+ }
+ }
+}
diff --git a/library/Icinga/Web/Cookie.php b/library/Icinga/Web/Cookie.php
new file mode 100644
index 0000000..283f07a
--- /dev/null
+++ b/library/Icinga/Web/Cookie.php
@@ -0,0 +1,299 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use InvalidArgumentException;
+
+/**
+ * A HTTP cookie
+ */
+class Cookie
+{
+ /**
+ * Domain of the cookie
+ *
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * The timestamp at which the cookie expires
+ *
+ * @var int
+ */
+ protected $expire;
+
+ /**
+ * Whether to protect the cookie against client side script code attempts to read the cookie
+ *
+ * Defaults to true.
+ *
+ * @var bool
+ */
+ protected $httpOnly = true;
+
+ /**
+ * Name of the cookie
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The path on the web server where the cookie is available
+ *
+ * Defaults to the base URL.
+ *
+ * @var string
+ */
+ protected $path;
+
+ /**
+ * Whether to send the cookie only over a secure connection
+ *
+ * Defaults to auto-detection so that if the current request was sent over a secure connection the secure flag will
+ * be set to true.
+ *
+ * @var bool
+ */
+ protected $secure;
+
+ /**
+ * Value of the cookie
+ *
+ * @var string
+ */
+ protected $value;
+
+ /**
+ * Create a new cookie
+ *
+ * @param string $name
+ * @param string $value
+ */
+ public function __construct($name, $value = null)
+ {
+ if (preg_match("/[=,; \t\r\n\013\014]/", $name)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Cookie name can\'t contain these characters: =,; \t\r\n\013\014 (%s)',
+ $name
+ ));
+ }
+ if (empty($name)) {
+ throw new InvalidArgumentException('The cookie name can\'t be empty');
+ }
+ $this->name = $name;
+ $this->value = $value;
+ }
+
+ /**
+ * Get the domain of the cookie
+ *
+ * @return string
+ */
+ public function getDomain()
+ {
+ if ($this->domain === null) {
+ $this->domain = Config::app()->get('cookie', 'domain');
+ }
+ return $this->domain;
+ }
+
+ /**
+ * Set the domain of the cookie
+ *
+ * @param string $domain
+ *
+ * @return $this
+ */
+ public function setDomain($domain)
+ {
+ $this->domain = $domain;
+ return $this;
+ }
+
+ /**
+ * Get the timestamp at which the cookie expires
+ *
+ * @return int
+ */
+ public function getExpire()
+ {
+ return $this->expire;
+ }
+
+ /**
+ * Set the timestamp at which the cookie expires
+ *
+ * @param int $expire
+ *
+ * @return $this
+ */
+ public function setExpire($expire)
+ {
+ $this->expire = $expire;
+ return $this;
+ }
+
+ /**
+ * Get whether to protect the cookie against client side script code attempts to read the cookie
+ *
+ * @return bool
+ */
+ public function isHttpOnly()
+ {
+ return $this->httpOnly;
+ }
+
+ /**
+ * Set whether to protect the cookie against client side script code attempts to read the cookie
+ *
+ * @param bool $httpOnly
+ *
+ * @return $this
+ */
+ public function setHttpOnly($httpOnly)
+ {
+ $this->httpOnly = $httpOnly;
+ return $this;
+ }
+
+ /**
+ * Get the name of the cookie
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Get the path on the web server where the cookie is available
+ *
+ * If the path has not been set either via {@link setPath()} or via config, the base URL will be returned.
+ *
+ * @return string
+ */
+ public function getPath()
+ {
+ if ($this->path === null) {
+ $path = Config::app()->get('cookie', 'path');
+ if ($path === null) {
+ // The following call could be used as default for ConfigObject::get(), but we prevent unnecessary
+ // function calls here, if the path is set in the config
+ $path = Icinga::app()->getRequest()->getBaseUrl() . '/'; // Zend has rtrim($baseUrl, '/')
+ }
+ $this->path = $path;
+ }
+ return $this->path;
+ }
+
+ /**
+ * Set the path on the web server where the cookie is available
+ *
+ * @param string $path
+ *
+ * @return $this
+ */
+ public function setPath($path)
+ {
+ $this->path = $path;
+ return $this;
+ }
+
+ /**
+ * Get whether to send the cookie only over a secure connection
+ *
+ * If the secure flag has not been set either via {@link setSecure()} or via config and if the current request was
+ * sent over a secure connection, true will be returned.
+ *
+ * @return bool
+ */
+ public function isSecure()
+ {
+ if ($this->secure === null) {
+ $secure = Config::app()->get('cookie', 'secure');
+ if ($secure === null) {
+ // The following call could be used as default for ConfigObject::get(), but we prevent unnecessary
+ // function calls here, if the secure flag is set in the config
+ $secure = Icinga::app()->getRequest()->isSecure();
+ }
+ $this->secure = $secure;
+ }
+ return $this->secure;
+ }
+
+ /**
+ * Set whether to send the cookie only over a secure connection
+ *
+ * @param bool $secure
+ *
+ * @return $this
+ */
+ public function setSecure($secure)
+ {
+ $this->secure = $secure;
+ return $this;
+ }
+
+ /**
+ * Get the value of the cookie
+ *
+ * @return string
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Set the value of the cookie
+ *
+ * @param string $value
+ *
+ * @return $this
+ */
+ public function setValue($value)
+ {
+ $this->value = $value;
+ return $this;
+ }
+
+ /**
+ * Create invalidation cookie
+ *
+ * This method clones the current cookie and sets its value to null and expire time to 1.
+ * That way, the cookie removes itself when it has been sent to and processed by the client.
+ *
+ * We're cloning the current cookie in order to meet the [RFC6265 spec](https://tools.ietf.org/search/rfc6265)
+ * regarding the `Path` and `Domain` attribute:
+ *
+ * > Finally, to remove a cookie, the server returns a Set-Cookie header with an expiration date in the past.
+ * > The server will be successful in removing the cookie only if the Path and the Domain attribute in the
+ * > Set-Cookie header match the values used when the cookie was created.
+ *
+ * Note that the cookie has to be sent to the client.
+ *
+ * # Example Usage
+ *
+ * ```php
+ * $response->setCookie(
+ * $cookie->forgetMe()
+ * );
+ * ```
+ *
+ * @return static
+ */
+ public function forgetMe()
+ {
+ $forgetMe = clone $this;
+
+ return $forgetMe
+ ->setValue(null)
+ ->setExpire(1);
+ }
+}
diff --git a/library/Icinga/Web/CookieSet.php b/library/Icinga/Web/CookieSet.php
new file mode 100644
index 0000000..019be29
--- /dev/null
+++ b/library/Icinga/Web/CookieSet.php
@@ -0,0 +1,58 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use ArrayIterator;
+use IteratorAggregate;
+use Traversable;
+
+/**
+ * Maintain a set of cookies
+ */
+class CookieSet implements IteratorAggregate
+{
+ /**
+ * Cookies in this set indexed by the cookie names
+ *
+ * @var Cookie[]
+ */
+ protected $cookies = array();
+
+ /**
+ * Get an iterator for traversing the cookies in this set
+ *
+ * @return ArrayIterator An iterator for traversing the cookies in this set
+ */
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->cookies);
+ }
+
+ /**
+ * Add a cookie to the set
+ *
+ * If a cookie with the same name already exists, the cookie will be overridden.
+ *
+ * @param Cookie $cookie The cookie to add
+ *
+ * @return $this
+ */
+ public function add(Cookie $cookie)
+ {
+ $this->cookies[$cookie->getName()] = $cookie;
+ return $this;
+ }
+
+ /**
+ * Get the cookie with the given name from the set
+ *
+ * @param string $name The name of the cookie
+ *
+ * @return Cookie|null The cookie with the given name or null if the cookie does not exist
+ */
+ public function get($name)
+ {
+ return isset($this->cookies[$name]) ? $this->cookies[$name] : null;
+ }
+}
diff --git a/library/Icinga/Web/Dom/DomNodeIterator.php b/library/Icinga/Web/Dom/DomNodeIterator.php
new file mode 100644
index 0000000..1ea20b8
--- /dev/null
+++ b/library/Icinga/Web/Dom/DomNodeIterator.php
@@ -0,0 +1,84 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Dom;
+
+use DOMNode;
+use IteratorIterator;
+use RecursiveIterator;
+
+/**
+ * Recursive iterator over a DOMNode
+ *
+ * Usage example:
+ * <code>
+ * <?php
+ *
+ * namespace Icinga\Example;
+ *
+ * use DOMDocument;
+ * use RecursiveIteratorIterator;
+ * use Icinga\Web\Dom\DomIterator;
+ *
+ * $doc = new DOMDocument();
+ * $doc->loadHTML(...);
+ * $dom = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST);
+ * foreach ($dom as $node) {
+ * ....
+ * }
+ * </code>
+ */
+class DomNodeIterator implements RecursiveIterator
+{
+ /**
+ * The node's children
+ *
+ * @var IteratorIterator
+ */
+ protected $children;
+
+ /**
+ * Create a new iterator over a DOMNode's children
+ *
+ * @param DOMNode $node
+ */
+ public function __construct(DOMNode $node)
+ {
+ $this->children = new IteratorIterator($node->childNodes);
+ }
+
+ public function current(): ?DOMNode
+ {
+ return $this->children->current();
+ }
+
+ public function key(): int
+ {
+ return $this->children->key();
+ }
+
+ public function next(): void
+ {
+ $this->children->next();
+ }
+
+ public function rewind(): void
+ {
+ $this->children->rewind();
+ }
+
+ public function valid(): bool
+ {
+ return $this->children->valid();
+ }
+
+ public function hasChildren(): bool
+ {
+ return $this->current()->hasChildNodes();
+ }
+
+ public function getChildren(): DomNodeIterator
+ {
+ return new static($this->current());
+ }
+}
diff --git a/library/Icinga/Web/FileCache.php b/library/Icinga/Web/FileCache.php
new file mode 100644
index 0000000..03f0c19
--- /dev/null
+++ b/library/Icinga/Web/FileCache.php
@@ -0,0 +1,293 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+class FileCache
+{
+ /**
+ * FileCache singleton instances
+ *
+ * @var array
+ */
+ protected static $instances = array();
+
+ /**
+ * Cache instance base directory
+ *
+ * @var string
+ */
+ protected $basedir;
+
+ /**
+ * Instance name
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * Whether the cache is enabled
+ *
+ * @var bool
+ */
+ protected $enabled = false;
+
+ /**
+ * The protected constructor creates a new instance with the given name
+ *
+ * @param string $name Cache instance name
+ */
+ protected function __construct($name)
+ {
+ $this->name = $name;
+ $tmpDir = sys_get_temp_dir();
+ $runtimePath = $tmpDir . '/FileCache_' . $name;
+ if (is_dir($runtimePath)) {
+ // Don't combine the following if with the above because else the elseif path will be evaluated if the
+ // runtime path exists and is not writeable
+ if (is_writeable($runtimePath)) {
+ $this->basedir = $runtimePath;
+ $this->enabled = true;
+ }
+ } elseif (is_dir($tmpDir) && is_writeable($tmpDir) && @mkdir($runtimePath, octdec('1750'), true)) {
+ // Suppress mkdir errors because it may error w/ no such file directory if the systemd private tmp directory
+ // for the web server has been removed
+ $this->basedir = $runtimePath;
+ $this->enabled = true;
+ }
+ }
+
+ /**
+ * Store the given content to the desired file name
+ *
+ * @param string $file new (relative) filename
+ * @param string $content the content to be stored
+ *
+ * @return bool whether the file has been stored
+ */
+ public function store($file, $content)
+ {
+ if (! $this->enabled) {
+ return false;
+ }
+
+ return file_put_contents($this->filename($file), $content);
+ }
+
+ /**
+ * Find out whether a given file exists
+ *
+ * @param string $file the (relative) filename
+ * @param int $newerThan optional timestamp to compare against
+ *
+ * @return bool whether such file exists
+ */
+ public function has($file, $newerThan = null)
+ {
+ if (! $this->enabled) {
+ return false;
+ }
+
+ $filename = $this->filename($file);
+
+ if (! file_exists($filename) || ! is_readable($filename)) {
+ return false;
+ }
+
+ if ($newerThan === null) {
+ return true;
+ }
+
+ $info = stat($filename);
+
+ if ($info === false) {
+ return false;
+ }
+
+ return (int) $newerThan < $info['mtime'];
+ }
+
+ /**
+ * Get a specific file or false if no such file available
+ *
+ * @param string $file the disired file name
+ *
+ * @return string|bool Filename content or false
+ */
+ public function get($file)
+ {
+ if ($this->has($file)) {
+ return file_get_contents($this->filename($file));
+ }
+
+ return false;
+ }
+
+ /**
+ * Send a specific file to the browser (output)
+ *
+ * @param string $file the disired file name
+ *
+ * @return bool Whether the file has been sent
+ */
+ public function send($file)
+ {
+ if ($this->has($file)) {
+ readfile($this->filename($file));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get absolute filename for a given file
+ *
+ * @param string $file the disired file name
+ *
+ * @return string absolute filename
+ */
+ protected function filename($file)
+ {
+ return $this->basedir . '/' . $file;
+ }
+
+ /**
+ * Prepare a sub directory with the given name and return its path
+ *
+ * @param string $name
+ *
+ * @return string|false Returns FALSE in case the cache is not enabled or an error occurred
+ */
+ public function directory($name)
+ {
+ if (! $this->enabled) {
+ return false;
+ }
+
+ $path = $this->filename($name);
+ if (! is_dir($path) && ! @mkdir($path, octdec('1750'), true)) {
+ return false;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Whether the given ETag matches a cached file
+ *
+ * If no ETag is given we'll try to fetch the one from the current
+ * HTTP request.
+ *
+ * @param string $file The cached file you want to check
+ * @param string $match The ETag to match against
+ *
+ * @return string|bool ETag on match, otherwise false
+ */
+ public function etagMatchesCachedFile($file, $match = null)
+ {
+ return self::etagMatchesFiles($this->filename($file), $match);
+ }
+
+ /**
+ * Create an ETag for the given file
+ *
+ * @param string $file The desired cache file
+ *
+ * @return string your ETag
+ */
+ public function etagForCachedFile($file)
+ {
+ return self::etagForFiles($this->filename($file));
+ }
+
+ /**
+ * Whether the given ETag matchesspecific file(s) on disk
+ *
+ * @param string|array $files file(s) to check
+ * @param string $match ETag to match against
+ *
+ * @return string|bool ETag on match, otherwise false
+ */
+ public static function etagMatchesFiles($files, $match = null)
+ {
+ if ($match === null) {
+ $match = isset($_SERVER['HTTP_IF_NONE_MATCH'])
+ ? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"')
+ : false;
+ }
+ if (! $match) {
+ return false;
+ }
+
+ if (preg_match('/([0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{8})-\w+/i', $match, $matches)) {
+ // Removes compression suffixes as our custom algorithm can't handle compressed cache files anyway
+ $match = $matches[1];
+ }
+
+ $etag = self::etagForFiles($files);
+ return $match === $etag ? $etag : false;
+ }
+
+ /**
+ * Create ETag for the given files
+ *
+ * Custom algorithm creating an ETag based on filenames, mtimes
+ * and file sizes. Supports single files or a list of files. This
+ * way we are able to create ETags for virtual files depending on
+ * multiple source files (e.g. compressed JS, CSS).
+ *
+ * @param string|array $files Single file or a list of such
+ *
+ * @return string The generated ETag
+ */
+ public static function etagForFiles($files)
+ {
+ if (is_string($files)) {
+ $files = array($files);
+ }
+
+ $sizes = array();
+ $mtimes = array();
+
+ foreach ($files as $file) {
+ $file = realpath($file);
+ if ($file !== false && $info = stat($file)) {
+ $mtimes[] = $info['mtime'];
+ $sizes[] = $info['size'];
+ } else {
+ $mtimes[] = time();
+ $sizes[] = 0;
+ }
+ }
+
+ return sprintf(
+ '%s-%s-%s',
+ hash('crc32', implode('|', $files)),
+ hash('crc32', implode('|', $sizes)),
+ hash('crc32', implode('|', $mtimes))
+ );
+ }
+
+ /**
+ * Factory creating your cache instance
+ *
+ * @param string $name Instance name
+ *
+ * @return FileCache
+ */
+ public static function instance($name = 'icingaweb')
+ {
+ if ($name !== 'icingaweb') {
+ $name = 'icingaweb/modules/' . $name;
+ }
+
+ if (!array_key_exists($name, self::$instances)) {
+ self::$instances[$name] = new static($name);
+ }
+
+ return self::$instances[$name];
+ }
+}
diff --git a/library/Icinga/Web/Form.php b/library/Icinga/Web/Form.php
new file mode 100644
index 0000000..b421849
--- /dev/null
+++ b/library/Icinga/Web/Form.php
@@ -0,0 +1,1666 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Web\Form\Element\DateTimePicker;
+use ipl\I18n\Translation;
+use Zend_Config;
+use Zend_Form;
+use Zend_Form_Element;
+use Zend_View_Interface;
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Security\SecurityException;
+use Icinga\Web\Form\ErrorLabeller;
+use Icinga\Web\Form\Decorator\Autosubmit;
+use Icinga\Web\Form\Element\CsrfCounterMeasure;
+
+/**
+ * Base class for forms providing CSRF protection, confirmation logic and auto submission
+ *
+ * @method \Zend_Form_Element[] getElements() {
+ * {@inheritdoc}
+ * @return \Zend_Form_Element[]
+ * }
+ */
+class Form extends Zend_Form
+{
+ use Translation {
+ translate as i18nTranslate;
+ translatePlural as i18nTranslatePlural;
+ }
+
+ /**
+ * The suffix to append to a field's hidden default field name
+ */
+ const DEFAULT_SUFFIX = '_default';
+
+ /**
+ * A form's default CSS classes
+ */
+ const DEFAULT_CLASSES = 'icinga-form icinga-controls';
+
+ /**
+ * Identifier for notifications of type error
+ */
+ const NOTIFICATION_ERROR = 0;
+
+ /**
+ * Identifier for notifications of type warning
+ */
+ const NOTIFICATION_WARNING = 1;
+
+ /**
+ * Identifier for notifications of type info
+ */
+ const NOTIFICATION_INFO = 2;
+
+ /**
+ * Whether this form has been created
+ *
+ * @var bool
+ */
+ protected $created = false;
+
+ /**
+ * This form's parent
+ *
+ * Gets automatically set upon calling addSubForm().
+ *
+ * @var Form
+ */
+ protected $_parent;
+
+ /**
+ * Whether the form is an API target
+ *
+ * When the form is an API target, the form evaluates as submitted if the request method equals the form method.
+ * That means, that the submit button and form identification are not taken into account. In addition, the CSRF
+ * counter measure will not be added to the form's elements.
+ *
+ * @var bool
+ */
+ protected $isApiTarget = false;
+
+ /**
+ * The request associated with this form
+ *
+ * @var Request
+ */
+ protected $request;
+
+ /**
+ * The callback to call instead of Form::onSuccess()
+ *
+ * @var callable
+ */
+ protected $onSuccess;
+
+ /**
+ * Label to use for the standard submit button
+ *
+ * @var string
+ */
+ protected $submitLabel;
+
+ /**
+ * Label to use for showing the user an activity indicator when submitting the form
+ *
+ * @var string
+ */
+ protected $progressLabel;
+
+ /**
+ * The url to redirect to upon success
+ *
+ * @var Url
+ */
+ protected $redirectUrl;
+
+ /**
+ * The view script to use when rendering this form
+ *
+ * @var string
+ */
+ protected $viewScript;
+
+ /**
+ * Whether this form should NOT add random generated "challenge" tokens that are associated with the user's current
+ * session in order to prevent Cross-Site Request Forgery (CSRF). It is the form's responsibility to verify the
+ * existence and correctness of this token
+ *
+ * @var bool
+ */
+ protected $tokenDisabled = false;
+
+ /**
+ * Name of the CSRF token element
+ *
+ * @var string
+ */
+ protected $tokenElementName = 'CSRFToken';
+
+ /**
+ * Whether this form should add a UID element being used to distinct different forms posting to the same action
+ *
+ * @var bool
+ */
+ protected $uidDisabled = false;
+
+ /**
+ * Name of the form identification element
+ *
+ * @var string
+ */
+ protected $uidElementName = 'formUID';
+
+ /**
+ * Whether the form should validate the sent data when being automatically submitted
+ *
+ * @var bool
+ */
+ protected $validatePartial = false;
+
+ /**
+ * Whether element ids will be protected against collisions by appending a request-specific unique identifier
+ *
+ * @var bool
+ */
+ protected $protectIds = true;
+
+ /**
+ * The cue that is appended to each element's label if it's required
+ *
+ * @var string
+ */
+ protected $requiredCue = '*';
+
+ /**
+ * The descriptions of this form
+ *
+ * @var array
+ */
+ protected $descriptions;
+
+ /**
+ * The notifications of this form
+ *
+ * @var array
+ */
+ protected $notifications;
+
+ /**
+ * The hints of this form
+ *
+ * @var array
+ */
+ protected $hints;
+
+ /**
+ * Whether the Autosubmit decorator should be applied to this form
+ *
+ * If this is true, the Autosubmit decorator is being applied to this form instead of to each of its elements.
+ *
+ * @var bool
+ */
+ protected $useFormAutosubmit = false;
+
+ /**
+ * Authentication manager
+ *
+ * @var Auth|null
+ */
+ private $auth;
+
+ /**
+ * Default element decorators
+ *
+ * @var array
+ */
+ public static $defaultElementDecorators = array(
+ array('Label', array('tag'=>'span', 'separator' => '', 'class' => 'control-label')),
+ array(array('labelWrap' => 'HtmlTag'), array('tag' => 'div', 'class' => 'control-label-group')),
+ array('ViewHelper', array('separator' => '')),
+ array('Help', array()),
+ array('Errors', array('separator' => '')),
+ array('HtmlTag', array('tag' => 'div', 'class' => 'control-group'))
+ );
+
+ /**
+ * (non-PHPDoc)
+ * @see \Zend_Form::construct() For the method documentation.
+ */
+ public function __construct($options = null)
+ {
+ // Zend's plugin loader reverses the order of added prefix paths thus trying our paths first before trying
+ // Zend paths
+ $this->addPrefixPaths(array(
+ array(
+ 'prefix' => 'Icinga\\Web\\Form\\Element\\',
+ 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Element'),
+ 'type' => static::ELEMENT
+ ),
+ array(
+ 'prefix' => 'Icinga\\Web\\Form\\Decorator\\',
+ 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Decorator'),
+ 'type' => static::DECORATOR
+ )
+ ));
+
+ if (! isset($options['attribs']['class'])) {
+ $options['attribs']['class'] = static::DEFAULT_CLASSES;
+ }
+
+ parent::__construct($options);
+ }
+
+ /**
+ * Set this form's parent
+ *
+ * @param Form $form
+ *
+ * @return $this
+ */
+ public function setParent(Form $form)
+ {
+ $this->_parent = $form;
+ return $this;
+ }
+
+ /**
+ * Return this form's parent
+ *
+ * @return Form
+ */
+ public function getParent()
+ {
+ return $this->_parent;
+ }
+
+ /**
+ * Set a callback that is called instead of this form's onSuccess method
+ *
+ * It is called using the following signature: (Form $this).
+ *
+ * @param callable $onSuccess Callback
+ *
+ * @return $this
+ *
+ * @throws ProgrammingError If the callback is not callable
+ */
+ public function setOnSuccess($onSuccess)
+ {
+ if (! is_callable($onSuccess)) {
+ throw new ProgrammingError('The option `onSuccess\' is not callable');
+ }
+ $this->onSuccess = $onSuccess;
+ return $this;
+ }
+
+ /**
+ * Set the label to use for the standard submit button
+ *
+ * @param string $label The label to use for the submit button
+ *
+ * @return $this
+ */
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+ return $this;
+ }
+
+ /**
+ * Return the label being used for the standard submit button
+ *
+ * @return string
+ */
+ public function getSubmitLabel()
+ {
+ return $this->submitLabel;
+ }
+
+ /**
+ * Set the label to use for showing the user an activity indicator when submitting the form
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setProgressLabel($label)
+ {
+ $this->progressLabel = $label;
+ return $this;
+ }
+
+ /**
+ * Return the label to use for showing the user an activity indicator when submitting the form
+ *
+ * @return string
+ */
+ public function getProgressLabel()
+ {
+ return $this->progressLabel;
+ }
+
+ /**
+ * Set the url to redirect to upon success
+ *
+ * @param string|Url $url The url to redirect to
+ *
+ * @return $this
+ *
+ * @throws ProgrammingError In case $url is neither a string nor a instance of Icinga\Web\Url
+ */
+ public function setRedirectUrl($url)
+ {
+ if (is_string($url)) {
+ $url = Url::fromPath($url, array(), $this->getRequest());
+ } elseif (! $url instanceof Url) {
+ throw new ProgrammingError('$url must be a string or instance of Icinga\Web\Url');
+ }
+
+ $this->redirectUrl = $url;
+ return $this;
+ }
+
+ /**
+ * Return the url to redirect to upon success
+ *
+ * @return Url
+ */
+ public function getRedirectUrl()
+ {
+ if ($this->redirectUrl === null) {
+ $this->redirectUrl = $this->getRequest()->getUrl();
+ if ($this->getMethod() === 'get') {
+ // Be sure to remove all form dependent params because we do not want to submit it again
+ $this->redirectUrl = $this->redirectUrl->without(array_keys($this->getElements()));
+ }
+ }
+
+ return $this->redirectUrl;
+ }
+
+ /**
+ * Set the view script to use when rendering this form
+ *
+ * @param string $viewScript The view script to use
+ *
+ * @return $this
+ */
+ public function setViewScript($viewScript)
+ {
+ $this->viewScript = $viewScript;
+ return $this;
+ }
+
+ /**
+ * Return the view script being used when rendering this form
+ *
+ * @return string
+ */
+ public function getViewScript()
+ {
+ return $this->viewScript;
+ }
+
+ /**
+ * Disable CSRF counter measure and remove its field if already added
+ *
+ * @param bool $disabled Set true in order to disable CSRF protection for this form, otherwise false
+ *
+ * @return $this
+ */
+ public function setTokenDisabled($disabled = true)
+ {
+ $this->tokenDisabled = (bool) $disabled;
+
+ if ($disabled && $this->getElement($this->tokenElementName) !== null) {
+ $this->removeElement($this->tokenElementName);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether CSRF counter measures are disabled for this form
+ *
+ * @return bool
+ */
+ public function getTokenDisabled()
+ {
+ return $this->tokenDisabled;
+ }
+
+ /**
+ * Set the name to use for the CSRF element
+ *
+ * @param string $name The name to set
+ *
+ * @return $this
+ */
+ public function setTokenElementName($name)
+ {
+ $this->tokenElementName = $name;
+ return $this;
+ }
+
+ /**
+ * Return the name of the CSRF element
+ *
+ * @return string
+ */
+ public function getTokenElementName()
+ {
+ return $this->tokenElementName;
+ }
+
+ /**
+ * Disable form identification and remove its field if already added
+ *
+ * @param bool $disabled Set true in order to disable identification for this form, otherwise false
+ *
+ * @return $this
+ */
+ public function setUidDisabled($disabled = true)
+ {
+ $this->uidDisabled = (bool) $disabled;
+
+ if ($disabled && $this->getElement($this->uidElementName) !== null) {
+ $this->removeElement($this->uidElementName);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether identification is disabled for this form
+ *
+ * @return bool
+ */
+ public function getUidDisabled()
+ {
+ return $this->uidDisabled;
+ }
+
+ /**
+ * Set the name to use for the form identification element
+ *
+ * @param string $name The name to set
+ *
+ * @return $this
+ */
+ public function setUidElementName($name)
+ {
+ $this->uidElementName = $name;
+ return $this;
+ }
+
+ /**
+ * Return the name of the form identification element
+ *
+ * @return string
+ */
+ public function getUidElementName()
+ {
+ return $this->uidElementName;
+ }
+
+ /**
+ * Set whether this form should validate the sent data when being automatically submitted
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setValidatePartial($state)
+ {
+ $this->validatePartial = $state;
+ return $this;
+ }
+
+ /**
+ * Return whether this form should validate the sent data when being automatically submitted
+ *
+ * @return bool
+ */
+ public function getValidatePartial()
+ {
+ return $this->validatePartial;
+ }
+
+ /**
+ * Set whether each element's id should be altered to avoid duplicates
+ *
+ * @param bool $value
+ *
+ * @return Form
+ */
+ public function setProtectIds($value = true)
+ {
+ $this->protectIds = (bool) $value;
+ return $this;
+ }
+
+ /**
+ * Return whether each element's id is being altered to avoid duplicates
+ *
+ * @return bool
+ */
+ public function getProtectIds()
+ {
+ return $this->protectIds;
+ }
+
+ /**
+ * Set the cue to append to each element's label if it's required
+ *
+ * @param string $cue
+ *
+ * @return Form
+ */
+ public function setRequiredCue($cue)
+ {
+ $this->requiredCue = $cue;
+ return $this;
+ }
+
+ /**
+ * Return the cue being appended to each element's label if it's required
+ *
+ * @return string
+ */
+ public function getRequiredCue()
+ {
+ return $this->requiredCue;
+ }
+
+ /**
+ * Set the descriptions for this form
+ *
+ * @param array $descriptions
+ *
+ * @return Form
+ */
+ public function setDescriptions(array $descriptions)
+ {
+ $this->descriptions = $descriptions;
+ return $this;
+ }
+
+ /**
+ * Add a description for this form
+ *
+ * If $description is an array the second value should be
+ * an array as well containing additional HTML properties.
+ *
+ * @param string|array $description
+ *
+ * @return Form
+ */
+ public function addDescription($description)
+ {
+ $this->descriptions[] = $description;
+ return $this;
+ }
+
+ /**
+ * Return the descriptions of this form
+ *
+ * @return array
+ */
+ public function getDescriptions()
+ {
+ if ($this->descriptions === null) {
+ return array();
+ }
+
+ return $this->descriptions;
+ }
+
+ /**
+ * Set the notifications for this form
+ *
+ * @param array $notifications
+ *
+ * @return $this
+ */
+ public function setNotifications(array $notifications)
+ {
+ $this->notifications = $notifications;
+ return $this;
+ }
+
+ /**
+ * Add a notification for this form
+ *
+ * If $notification is an array the second value should be
+ * an array as well containing additional HTML properties.
+ *
+ * @param string|array $notification
+ * @param int $type
+ *
+ * @return $this
+ */
+ public function addNotification($notification, $type)
+ {
+ $this->notifications[$type][] = $notification;
+ return $this;
+ }
+
+ /**
+ * Return the notifications of this form
+ *
+ * @return array
+ */
+ public function getNotifications()
+ {
+ if ($this->notifications === null) {
+ return array();
+ }
+
+ return $this->notifications;
+ }
+
+ /**
+ * Set the hints for this form
+ *
+ * @param array $hints
+ *
+ * @return $this
+ */
+ public function setHints(array $hints)
+ {
+ $this->hints = $hints;
+ return $this;
+ }
+
+ /**
+ * Add a hint for this form
+ *
+ * If $hint is an array the second value should be an
+ * array as well containing additional HTML properties.
+ *
+ * @param string|array $hint
+ *
+ * @return $this
+ */
+ public function addHint($hint)
+ {
+ $this->hints[] = $hint;
+ return $this;
+ }
+
+ /**
+ * Return the hints of this form
+ *
+ * @return array
+ */
+ public function getHints()
+ {
+ if ($this->hints === null) {
+ return array();
+ }
+
+ return $this->hints;
+ }
+
+ /**
+ * Set whether the Autosubmit decorator should be applied to this form
+ *
+ * If true, the Autosubmit decorator is being applied to this form instead of to each of its elements.
+ *
+ * @param bool $state
+ *
+ * @return Form
+ */
+ public function setUseFormAutosubmit($state = true)
+ {
+ $this->useFormAutosubmit = (bool) $state;
+ if ($this->useFormAutosubmit) {
+ $this->setAttrib('data-progress-element', 'header-' . $this->getId());
+ } else {
+ $this->removeAttrib('data-progress-element');
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether the Autosubmit decorator is being applied to this form
+ *
+ * @return bool
+ */
+ public function getUseFormAutosubmit()
+ {
+ return $this->useFormAutosubmit;
+ }
+
+ /**
+ * Get whether the form is an API target
+ *
+ * @todo This should probably only return true if the request is also an api request
+ * @return bool
+ */
+ public function getIsApiTarget()
+ {
+ return $this->isApiTarget;
+ }
+
+ /**
+ * Set whether the form is an API target
+ *
+ * @param bool $isApiTarget
+ *
+ * @return $this
+ */
+ public function setIsApiTarget($isApiTarget = true)
+ {
+ $this->isApiTarget = (bool) $isApiTarget;
+ return $this;
+ }
+
+ /**
+ * Create this form
+ *
+ * @param array $formData The data sent by the user
+ *
+ * @return $this
+ */
+ public function create(array $formData = array())
+ {
+ if (! $this->created) {
+ $this->createElements($formData);
+ $this->addFormIdentification()
+ ->addCsrfCounterMeasure()
+ ->addSubmitButton();
+
+ // Use Form::getAttrib() instead of Form::getAction() here because we want to explicitly check against
+ // null. Form::getAction() would return the empty string '' if the action is not set.
+ // For not setting the action attribute use Form::setAction(''). This is required for for the
+ // accessibility's enable/disable auto-refresh mechanic
+ if ($this->getAttrib('action') === null) {
+ $action = $this->getRequest()->getUrl();
+ if ($this->getMethod() === 'get') {
+ $action = $action->without(array_keys($this->getElements()));
+ }
+
+ // TODO(el): Re-evalute this necessity.
+ // JavaScript could use the container'sURL if there's no action set.
+ // We MUST set an action as JS gets confused otherwise, if
+ // this form is being displayed in an additional column
+ $this->setAction($action);
+ }
+
+ $this->created = true;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Create and add elements to this form
+ *
+ * Intended to be implemented by concrete form classes.
+ *
+ * @param array $formData The data sent by the user
+ */
+ public function createElements(array $formData)
+ {
+ }
+
+ /**
+ * Perform actions after this form was submitted using a valid request
+ *
+ * Intended to be implemented by concrete form classes. The base implementation returns always FALSE.
+ *
+ * @return null|bool Return FALSE in case no redirect should take place
+ */
+ public function onSuccess()
+ {
+ return false;
+ }
+
+ /**
+ * Perform actions when no form dependent data was sent
+ *
+ * Intended to be implemented by concrete form classes.
+ */
+ public function onRequest()
+ {
+ }
+
+ /**
+ * Add a submit button to this form
+ *
+ * Uses the label previously set with Form::setSubmitLabel(). Overwrite this
+ * method in order to add multiple submit buttons or one with a custom name.
+ *
+ * @return $this
+ */
+ public function addSubmitButton()
+ {
+ $submitLabel = $this->getSubmitLabel();
+ if ($submitLabel) {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ array(
+ 'class' => 'btn-primary',
+ 'ignore' => true,
+ 'label' => $submitLabel,
+ 'data-progress-label' => $this->getProgressLabel(),
+ 'decorators' => array(
+ 'ViewHelper',
+ array('Spinner', array('separator' => '')),
+ array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls'))
+ )
+ )
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a subform
+ *
+ * @param Zend_Form $form The subform to add
+ * @param string $name The name of the subform or null to use the name of $form
+ * @param int $order The location where to insert the form
+ *
+ * @return Zend_Form
+ */
+ public function addSubForm(Zend_Form $form, $name = null, $order = null)
+ {
+ if ($form instanceof self) {
+ $form->setDecorators(array('FormElements')); // TODO: Makes it difficult to customise subform decorators..
+ $form->setSubmitLabel('');
+ $form->setTokenDisabled();
+ $form->setUidDisabled();
+ $form->setParent($this);
+ }
+
+ if ($name === null) {
+ $name = $form->getName();
+ }
+
+ return parent::addSubForm($form, $name, $order);
+ }
+
+ /**
+ * Create a new element
+ *
+ * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set the
+ * `disableLoadDefaultDecorators' option to any other value than `true'. For loading custom element decorators use
+ * the 'decorators' option.
+ *
+ * @param string $type The type of the element
+ * @param string $name The name of the element
+ * @param mixed $options The options for the element
+ *
+ * @return Zend_Form_Element
+ *
+ * @see Form::$defaultElementDecorators For Icinga Web 2's default element decorators.
+ */
+ public function createElement($type, $name, $options = null)
+ {
+ if ($options !== null) {
+ if ($options instanceof Zend_Config) {
+ $options = $options->toArray();
+ }
+ if (! isset($options['decorators'])
+ && ! array_key_exists('disabledLoadDefaultDecorators', $options)
+ ) {
+ $options['decorators'] = static::$defaultElementDecorators;
+ if (! isset($options['data-progress-label']) && ($type === 'submit'
+ || ($type === 'button' && isset($options['type']) && $options['type'] === 'submit'))
+ ) {
+ array_splice($options['decorators'], 1, 0, array(array('Spinner', array('separator' => ''))));
+ } elseif ($type === 'hidden') {
+ $options['decorators'] = array('ViewHelper');
+ }
+ }
+ } else {
+ $options = array('decorators' => static::$defaultElementDecorators);
+ if ($type === 'submit') {
+ array_splice($options['decorators'], 1, 0, array(array('Spinner', array('separator' => ''))));
+ } elseif ($type === 'hidden') {
+ $options['decorators'] = array('ViewHelper');
+ }
+ }
+
+ $el = parent::createElement($type, $name, $options);
+ $el->setTranslator(new ErrorLabeller(array('element' => $el)));
+
+ $el->addPrefixPaths(array(
+ array(
+ 'prefix' => 'Icinga\\Web\\Form\\Validator\\',
+ 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Validator'),
+ 'type' => $el::VALIDATE
+ )
+ ));
+
+ if ($this->protectIds) {
+ $el->setAttrib('id', $this->getRequest()->protectId($this->getId(false) . '_' . $el->getId()));
+ }
+
+ if ($el->getAttrib('autosubmit')) {
+ if ($this->getUseFormAutosubmit()) {
+ $warningId = 'autosubmit_warning_' . $el->getId();
+ $warningText = $this->getView()->escape($this->translate(
+ 'This page will be automatically updated upon change of the value'
+ ));
+ $autosubmitDecorator = $this->_getDecorator('Callback', array(
+ 'placement' => 'PREPEND',
+ 'callback' => function ($content) use ($warningId, $warningText) {
+ return '<span class="sr-only" id="' . $warningId . '">' . $warningText . '</span>';
+ }
+ ));
+ } else {
+ $autosubmitDecorator = new Autosubmit();
+ $autosubmitDecorator->setAccessible();
+ $warningId = $autosubmitDecorator->getWarningId($el);
+ }
+
+ $decorators = $el->getDecorators();
+ $pos = array_search('Zend_Form_Decorator_ViewHelper', array_keys($decorators), true) + 1;
+ $el->setDecorators(
+ array_slice($decorators, 0, $pos, true)
+ + array('autosubmit' => $autosubmitDecorator)
+ + array_slice($decorators, $pos, count($decorators) - $pos, true)
+ );
+
+ if (($describedBy = $el->getAttrib('aria-describedby')) !== null) {
+ $el->setAttrib('aria-describedby', $describedBy . ' ' . $warningId);
+ } else {
+ $el->setAttrib('aria-describedby', $warningId);
+ }
+
+ $class = $el->getAttrib('class');
+ if (is_array($class)) {
+ $class[] = 'autosubmit';
+ } elseif ($class === null) {
+ $class = 'autosubmit';
+ } else {
+ $class .= ' autosubmit';
+ }
+ $el->setAttrib('class', $class);
+
+ unset($el->autosubmit);
+ }
+
+ if ($el->getAttrib('preserveDefault')) {
+ $el->addDecorator(
+ array('preserveDefault' => 'HtmlTag'),
+ array(
+ 'tag' => 'input',
+ 'type' => 'hidden',
+ 'name' => $name . static::DEFAULT_SUFFIX,
+ 'value' => $el instanceof DateTimePicker
+ ? $el->getValue()->format($el->getFormat())
+ : $el->getValue()
+ )
+ );
+
+ unset($el->preserveDefault);
+ }
+
+ return $this->ensureElementAccessibility($el);
+ }
+
+ /**
+ * Add accessibility related attributes
+ *
+ * @param Zend_Form_Element $element
+ *
+ * @return Zend_Form_Element
+ */
+ public function ensureElementAccessibility(Zend_Form_Element $element)
+ {
+ if ($element->isRequired()) {
+ $element->setAttrib('aria-required', 'true'); // ARIA
+ $element->setAttrib('required', ''); // HTML5
+ if (($cue = $this->getRequiredCue()) !== null && ($label = $element->getDecorator('label')) !== false) {
+ $element->setLabel($this->getView()->escape($element->getLabel()));
+ $label->setOption('escape', false);
+ $label->setRequiredSuffix(sprintf(' <span aria-hidden="true">%s</span>', $cue));
+ }
+ }
+
+ if ($element->getDescription() !== null && ($help = $element->getDecorator('help')) !== false) {
+ if (($describedBy = $element->getAttrib('aria-describedby')) !== null) {
+ // Assume that it's because of the element being of type autosubmit or
+ // that one who did set the property manually removes the help decorator
+ // in case it has already an aria-describedby property set
+ $element->setAttrib(
+ 'aria-describedby',
+ $help->setAccessible()->getDescriptionId($element) . ' ' . $describedBy
+ );
+ } else {
+ $element->setAttrib('aria-describedby', $help->setAccessible()->getDescriptionId($element));
+ }
+ }
+
+ return $element;
+ }
+
+ /**
+ * Add a field with a unique and form specific ID
+ *
+ * @return $this
+ */
+ public function addFormIdentification()
+ {
+ if (! $this->uidDisabled && $this->getElement($this->uidElementName) === null) {
+ $this->addElement(
+ 'hidden',
+ $this->uidElementName,
+ array(
+ 'ignore' => true,
+ 'value' => $this->getName(),
+ 'decorators' => array('ViewHelper')
+ )
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add CSRF counter measure field to this form
+ *
+ * @return $this
+ */
+ public function addCsrfCounterMeasure()
+ {
+ if (! $this->tokenDisabled) {
+ $request = $this->getRequest();
+ if (! $request->isXmlHttpRequest()
+ && ($this->getIsApiTarget() || $request->isApiRequest())
+ ) {
+ return $this;
+ }
+ if ($this->getElement($this->tokenElementName) === null) {
+ $this->addElement('CsrfCounterMeasure', $this->tokenElementName);
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Creates the form if not created yet.
+ *
+ * @param array $values
+ *
+ * @return $this
+ */
+ public function setDefaults(array $values)
+ {
+ $this->create($values);
+ return parent::setDefaults($values);
+ }
+
+ /**
+ * Populate the elements with the given values
+ *
+ * @param array $defaults The values to populate the elements with
+ *
+ * @return $this
+ */
+ public function populate(array $defaults)
+ {
+ $this->create($defaults);
+ $this->preserveDefaults($this, $defaults);
+ return parent::populate($defaults);
+ }
+
+ /**
+ * Recurse the given form and unset all unchanged default values
+ *
+ * @param Zend_Form $form
+ * @param array $defaults
+ */
+ protected function preserveDefaults(Zend_Form $form, array &$defaults)
+ {
+ foreach ($form->getElements() as $name => $element) {
+ if ((array_key_exists($name, $defaults)
+ && array_key_exists($name . static::DEFAULT_SUFFIX, $defaults)
+ && $defaults[$name] === $defaults[$name . static::DEFAULT_SUFFIX])
+ || $element->getAttrib('disabled')
+ ) {
+ unset($defaults[$name]);
+ }
+ }
+
+ foreach ($form->getSubForms() as $_ => $subForm) {
+ $this->preserveDefaults($subForm, $defaults);
+ }
+ }
+
+ /**
+ * Process the given request using this form
+ *
+ * Redirects to the url set with setRedirectUrl() upon success. See onSuccess()
+ * and onRequest() wherewith you can customize the processing logic.
+ *
+ * @param Request $request The request to be processed
+ *
+ * @return Request The request supposed to be processed
+ */
+ public function handleRequest(Request $request = null)
+ {
+ if ($request === null) {
+ $request = $this->getRequest();
+ } else {
+ $this->request = $request;
+ }
+
+ $formData = $this->getRequestData();
+ if ($this->getIsApiTarget()
+ // TODO: Very very bad, wasSent() must not be bypassed if it's only an api request but not an qpi target
+ || $this->getRequest()->isApiRequest()
+ || $this->getUidDisabled()
+ || $this->wasSent($formData)
+ ) {
+ $this->populate($formData); // Necessary to get isSubmitted() to work
+ if (! $this->getSubmitLabel() || $this->isSubmitted()) {
+ if ($this->isValid($formData)
+ && (($this->onSuccess !== null && false !== call_user_func($this->onSuccess, $this))
+ || ($this->onSuccess === null && false !== $this->onSuccess()))
+ ) {
+ // TODO: Still bad. An api target must not behave as one if it's not an api request
+ if ($this->getIsApiTarget() || $this->getRequest()->isApiRequest()) {
+ // API targets and API requests will never redirect but immediately respond w/ JSON-encoded
+ // notifications
+ $notifications = Notification::getInstance()->popMessages();
+ $message = null;
+ foreach ($notifications as $notification) {
+ if ($notification->type === Notification::SUCCESS) {
+ $message = $notification->message;
+ break;
+ }
+ }
+ $this->getResponse()->json()
+ ->setSuccessData($message !== null ? array('message' => $message) : null)
+ ->sendResponse();
+ } else {
+ $this->getResponse()->redirectAndExit($this->getRedirectUrl());
+ }
+ // TODO: Still bad. An api target must not behave as one if it's not an api request
+ } elseif ($this->getIsApiTarget() || $this->getRequest()->isApiRequest()) {
+ $this->getResponse()->json()->setFailData($this->getMessages())->sendResponse();
+ }
+ } elseif ($this->getValidatePartial()) {
+ // The form can't be processed but we may want to show validation errors though
+ $this->isValidPartial($formData);
+ }
+ } else {
+ $this->onRequest();
+ }
+
+ return $request;
+ }
+
+ /**
+ * Return whether the submit button of this form was pressed
+ *
+ * When overwriting Form::addSubmitButton() be sure to overwrite this method as well.
+ *
+ * @return bool True in case it was pressed, False otherwise or no submit label was set
+ */
+ public function isSubmitted()
+ {
+ $requestMethod = $this->getRequest()->getMethod();
+ if (strtolower($requestMethod ?: '') !== $this->getMethod()) {
+ return false;
+ }
+ if ($this->getIsApiTarget() || $this->getRequest()->isApiRequest()) {
+ return true;
+ }
+ if ($this->getSubmitLabel()) {
+ return $this->getElement('btn_submit')->isChecked();
+ }
+
+ return false;
+ }
+
+ /**
+ * Return whether the data sent by the user refers to this form
+ *
+ * Ensures that the correct form gets processed in case there are multiple forms
+ * with equal submit button names being posted against the same route.
+ *
+ * @param array $formData The data sent by the user
+ *
+ * @return bool Whether the given data refers to this form
+ */
+ public function wasSent(array $formData)
+ {
+ return isset($formData[$this->uidElementName]) && $formData[$this->uidElementName] === $this->getName();
+ }
+
+ /**
+ * Return whether the given values (possibly incomplete) are valid
+ *
+ * Unlike Zend_Form::isValid() this will not set NULL as value for
+ * an element that is not present in the given data.
+ *
+ * @param array $formData The data to validate
+ *
+ * @return bool
+ */
+ public function isValidPartial(array $formData)
+ {
+ $this->create($formData);
+
+ foreach ($this->getElements() as $name => $element) {
+ if (array_key_exists($name, $formData)) {
+ if ($element->getAttrib('disabled')) {
+ // Ensure that disabled elements are not overwritten
+ // (http://www.zendframework.com/issues/browse/ZF-6909)
+ $formData[$name] = $element->getValue();
+ } elseif (array_key_exists($name . static::DEFAULT_SUFFIX, $formData)
+ && $formData[$name] === $formData[$name . static::DEFAULT_SUFFIX]
+ ) {
+ unset($formData[$name]);
+ }
+ }
+ }
+
+ return parent::isValidPartial($formData);
+ }
+
+ /**
+ * Return whether the given values are valid
+ *
+ * @param array $formData The data to validate
+ *
+ * @return bool
+ */
+ public function isValid($formData)
+ {
+ $this->create($formData);
+
+ // Ensure that disabled elements are not overwritten (http://www.zendframework.com/issues/browse/ZF-6909)
+ foreach ($this->getElements() as $name => $element) {
+ if ($element->getAttrib('disabled')) {
+ $formData[$name] = $element->getValue();
+ }
+ }
+
+ return parent::isValid($formData);
+ }
+
+ /**
+ * Remove all elements of this form
+ *
+ * @return self
+ */
+ public function clearElements()
+ {
+ $this->created = false;
+ return parent::clearElements();
+ }
+
+ /**
+ * Load the default decorators
+ *
+ * Overwrites Zend_Form::loadDefaultDecorators to avoid having
+ * the HtmlTag-Decorator added and to provide view script usage
+ *
+ * @return $this
+ */
+ public function loadDefaultDecorators()
+ {
+ if ($this->loadDefaultDecoratorsIsDisabled()) {
+ return $this;
+ }
+
+ $decorators = $this->getDecorators();
+ if (empty($decorators)) {
+ if ($this->viewScript) {
+ $this->addDecorator('ViewScript', array(
+ 'viewScript' => $this->viewScript,
+ 'form' => $this
+ ));
+ } else {
+ $this->addDecorator('Description', array('tag' => 'h1'));
+ if ($this->getUseFormAutosubmit()) {
+ $this->getDecorator('Description')->setEscape(false);
+ $this->addDecorator(
+ 'HtmlTag',
+ array(
+ 'tag' => 'div',
+ 'class' => 'header',
+ 'id' => 'header-' . $this->getId()
+ )
+ );
+ }
+
+ $this->addDecorator('FormDescriptions')
+ ->addDecorator('FormNotifications')
+ ->addDecorator('FormErrors', array('onlyCustomFormErrors' => true))
+ ->addDecorator('FormElements')
+ ->addDecorator('FormHints')
+ //->addDecorator('HtmlTag', array('tag' => 'dl', 'class' => 'zend_form'))
+ ->addDecorator('Form');
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get element id
+ *
+ * Returns the protected id, in case id protection is enabled.
+ *
+ * @param bool $protect
+ *
+ * @return string
+ */
+ public function getId($protect = true)
+ {
+ $id = parent::getId();
+ return $protect && $this->protectIds ? $this->getRequest()->protectId($id) : $id;
+ }
+
+ /**
+ * Return the name of this form
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ $name = parent::getName();
+ if (! $name) {
+ $name = get_class($this);
+ $this->setName($name);
+ $name = parent::getName();
+ }
+ return $name;
+ }
+
+ /**
+ * Retrieve form description
+ *
+ * This will return the escaped description with the autosubmit warning icon if form autosubmit is enabled.
+ *
+ * @return string
+ */
+ public function getDescription()
+ {
+ $description = parent::getDescription();
+ if ($description && $this->getUseFormAutosubmit()) {
+ $autosubmit = $this->_getDecorator('Autosubmit', array('accessible' => true));
+ $autosubmit->setElement($this);
+ $description = $autosubmit->render($this->getView()->escape($description));
+ }
+
+ return $description;
+ }
+
+ /**
+ * Set the action to submit this form against
+ *
+ * Note that if you'll pass a instance of URL, Url::getAbsoluteUrl('&') is called to set the action.
+ *
+ * @param Url|string $action
+ *
+ * @return $this
+ */
+ public function setAction($action)
+ {
+ if ($action instanceof Url) {
+ $action = $action->getAbsoluteUrl('&');
+ }
+
+ return parent::setAction($action);
+ }
+
+ /**
+ * Set form description
+ *
+ * Alias for Zend_Form::setDescription().
+ *
+ * @param string $value
+ *
+ * @return Form
+ */
+ public function setTitle($value)
+ {
+ return $this->setDescription($value);
+ }
+
+ /**
+ * Return the request associated with this form
+ *
+ * Returns the global request if none has been set for this form yet.
+ *
+ * @return Request
+ */
+ public function getRequest()
+ {
+ if ($this->request === null) {
+ $this->request = Icinga::app()->getRequest();
+ }
+
+ return $this->request;
+ }
+
+ /**
+ * Set the request
+ *
+ * @param Request $request
+ *
+ * @return $this
+ */
+ public function setRequest(Request $request)
+ {
+ $this->request = $request;
+ return $this;
+ }
+
+ /**
+ * Return the current Response
+ *
+ * @return Response
+ */
+ public function getResponse()
+ {
+ return Icinga::app()->getFrontController()->getResponse();
+ }
+
+ /**
+ * Return the request data based on this form's request method
+ *
+ * @return array
+ */
+ protected function getRequestData()
+ {
+ $requestMethod = $this->getRequest()->getMethod();
+ if (strtolower($requestMethod ?: '') === $this->getMethod()) {
+ return $this->request->{'get' . ($this->request->isPost() ? 'Post' : 'Query')}();
+ }
+
+ return array();
+ }
+
+ /**
+ * Get the translation domain for this form
+ *
+ * The returned translation domain is either determined based on this form's qualified name or it is the default
+ * 'icinga' domain
+ *
+ * @return string
+ */
+ protected function getTranslationDomain()
+ {
+ $parts = explode('\\', get_called_class());
+ if (count($parts) > 1 && $parts[1] === 'Module') {
+ // Assume format Icinga\Module\ModuleName\Forms\...
+ return strtolower($parts[2]);
+ }
+
+ return 'icinga';
+ }
+
+ /**
+ * Translate a string
+ *
+ * @param string $text The string to translate
+ * @param string|null $context Optional parameter for context based translation
+ *
+ * @return string The translated string
+ */
+ protected function translate($text, $context = null)
+ {
+ $this->translationDomain = $this->getTranslationDomain();
+
+ return $this->i18nTranslate($text, $context);
+ }
+
+ /**
+ * Translate a plural string
+ *
+ * @param string $textSingular The string in singular form to translate
+ * @param string $textPlural The string in plural form to translate
+ * @param integer $number The amount to determine from whether to return singular or plural
+ * @param string|null $context Optional parameter for context based translation
+ *
+ * @return string The translated string
+ */
+ protected function translatePlural($textSingular, $textPlural, $number, $context = null)
+ {
+ $this->translationDomain = $this->getTranslationDomain();
+
+ return $this->i18nTranslatePlural($textSingular, $textPlural, $number, $context);
+ }
+
+ /**
+ * Render this form
+ *
+ * @param Zend_View_Interface $view The view context to use
+ *
+ * @return string
+ */
+ public function render(Zend_View_Interface $view = null)
+ {
+ $this->create();
+ return parent::render($view);
+ }
+
+ /**
+ * Get the authentication manager
+ *
+ * @return Auth
+ */
+ public function Auth()
+ {
+ if ($this->auth === null) {
+ $this->auth = Auth::getInstance();
+ }
+ return $this->auth;
+ }
+
+ /**
+ * Whether the current user has the given permission
+ *
+ * @param string $permission Name of the permission
+ *
+ * @return bool
+ */
+ public function hasPermission($permission)
+ {
+ return $this->Auth()->hasPermission($permission);
+ }
+
+ /**
+ * Assert that the current user has the given permission
+ *
+ * @param string $permission Name of the permission
+ *
+ * @throws SecurityException If the current user lacks the given permission
+ */
+ public function assertPermission($permission)
+ {
+ if (! $this->Auth()->hasPermission($permission)) {
+ throw new SecurityException('No permission for %s', $permission);
+ }
+ }
+
+ /**
+ * Add a error notification
+ *
+ * @param string|array $message The notification message
+ * @param bool $markAsError Whether to prevent the form from being successfully validated or not
+ *
+ * @return $this
+ */
+ public function error($message, $markAsError = true)
+ {
+ if ($this->getIsApiTarget()) {
+ $this->addErrorMessage($message);
+ } else {
+ $this->addNotification($message, self::NOTIFICATION_ERROR);
+ }
+
+ if ($markAsError) {
+ $this->markAsError();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a warning notification
+ *
+ * @param string|array $message The notification message
+ * @param bool $markAsError Whether to prevent the form from being successfully validated or not
+ *
+ * @return $this
+ */
+ public function warning($message, $markAsError = true)
+ {
+ if ($this->getIsApiTarget()) {
+ $this->addErrorMessage($message);
+ } else {
+ $this->addNotification($message, self::NOTIFICATION_WARNING);
+ }
+
+ if ($markAsError) {
+ $this->markAsError();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a info notification
+ *
+ * @param string|array $message The notification message
+ * @param bool $markAsError Whether to prevent the form from being successfully validated or not
+ *
+ * @return $this
+ */
+ public function info($message, $markAsError = true)
+ {
+ if ($this->getIsApiTarget()) {
+ $this->addErrorMessage($message);
+ } else {
+ $this->addNotification($message, self::NOTIFICATION_INFO);
+ }
+
+ if ($markAsError) {
+ $this->markAsError();
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/Autosubmit.php b/library/Icinga/Web/Form/Decorator/Autosubmit.php
new file mode 100644
index 0000000..4405d0b
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/Autosubmit.php
@@ -0,0 +1,133 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Zend_Form_Decorator_Abstract;
+use Icinga\Application\Icinga;
+use Icinga\Web\View;
+use Icinga\Web\Form;
+
+/**
+ * Decorator to add an icon and a submit button encapsulated in noscript-tags
+ *
+ * The icon is shown in JS environments to indicate that a specific form field does automatically request an update
+ * of its form upon it has changed. The button allows users in non-JS environments to trigger the update manually.
+ */
+class Autosubmit extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * Whether a hidden <span> should be created with the same warning as in the icon label
+ *
+ * @var bool
+ */
+ protected $accessible;
+
+ /**
+ * The id used to identify the auto-submit warning associated with the decorated form element
+ *
+ * @var string
+ */
+ protected $warningId;
+
+ /**
+ * Set whether a hidden <span> should be created with the same warning as in the icon label
+ *
+ * @param bool $state
+ *
+ * @return Autosubmit
+ */
+ public function setAccessible($state = true)
+ {
+ $this->accessible = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether a hidden <span> is being created with the same warning as in the icon label
+ *
+ * @return bool
+ */
+ public function getAccessible()
+ {
+ if ($this->accessible === null) {
+ $this->accessible = $this->getOption('accessible') ?: false;
+ }
+
+ return $this->accessible;
+ }
+
+ /**
+ * Return the id used to identify the auto-submit warning associated with the decorated element
+ *
+ * @param mixed $element The element for which to generate a id
+ *
+ * @return string
+ */
+ public function getWarningId($element = null)
+ {
+ if ($this->warningId === null) {
+ $element = $element ?: $this->getElement();
+ $this->warningId = 'autosubmit_warning_' . $element->getId();
+ }
+
+ return $this->warningId;
+ }
+
+ /**
+ * Return the current view
+ *
+ * @return View
+ */
+ protected function getView()
+ {
+ return Icinga::app()->getViewRenderer()->view;
+ }
+
+ /**
+ * Add a auto-submit icon and submit button encapsulated in noscript-tags to the element
+ *
+ * @param string $content The html rendered so far
+ *
+ * @return string The updated html
+ */
+ public function render($content = '')
+ {
+ if ($content) {
+ $isForm = $this->getElement() instanceof Form;
+ $warning = $isForm
+ ? t('This page will be automatically updated upon change of any of this form\'s fields')
+ : t('This page will be automatically updated upon change of the value');
+ $content .= $this->getView()->icon('cw', $warning, array(
+ 'aria-hidden' => $isForm ? 'false' : 'true',
+ 'class' => 'spinner autosubmit-info'
+ ));
+ if (! $isForm && $this->getAccessible()) {
+ $content = '<span id="'
+ . $this->getWarningId()
+ . '" class="sr-only">'
+ . $warning
+ . '</span>'
+ . $content;
+ }
+
+ $content .= sprintf(
+ '<noscript><button'
+ . ' name="noscript_apply"'
+ . ' class="noscript-apply"'
+ . ' type="submit"'
+ . ' value="1"'
+ . ($this->getAccessible() ? ' aria-label="%1$s"' : '')
+ . ' title="%1$s"'
+ . '>%2$s</button></noscript>',
+ $isForm
+ ? t('Push this button to update the form to reflect the changes that were made below')
+ : t('Push this button to update the form to reflect the change'
+ . ' that was made in the field on the left'),
+ $this->getView()->icon('cw') . t('Apply')
+ );
+ }
+
+ return $content;
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/ConditionalHidden.php b/library/Icinga/Web/Form/Decorator/ConditionalHidden.php
new file mode 100644
index 0000000..0f84535
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/ConditionalHidden.php
@@ -0,0 +1,35 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Zend_Form_Decorator_Abstract;
+
+/**
+ * Decorator to hide elements using a &gt;noscript&lt; tag instead of
+ * type='hidden' or css styles.
+ *
+ * This allows to hide depending elements for browsers with javascript
+ * (who can then automatically refresh their pages) but show them in
+ * case JavaScript is disabled
+ */
+class ConditionalHidden extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * Generate a field that will be wrapped in <noscript> tag if the
+ * "condition" attribute is set and false or 0
+ *
+ * @param string $content The tag's content
+ *
+ * @return string The generated tag
+ */
+ public function render($content = '')
+ {
+ $attributes = $this->getElement()->getAttribs();
+ $condition = isset($attributes['condition']) ? $attributes['condition'] : 1;
+ if ($condition != 1) {
+ $content = '<noscript>' . $content . '</noscript>';
+ }
+ return $content;
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/ElementDoubler.php b/library/Icinga/Web/Form/Decorator/ElementDoubler.php
new file mode 100644
index 0000000..2da5646
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/ElementDoubler.php
@@ -0,0 +1,63 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Zend_Form_Element;
+use Zend_Form_Decorator_Abstract;
+
+/**
+ * A decorator that will double a single element of a display group
+ *
+ * The options `condition', `double' and `attributes' can be passed to the constructor and are used to affect whether
+ * the doubling should take effect, which element should be doubled and which HTML attributes should be applied to the
+ * doubled element, respectively.
+ *
+ * `condition' must be an element's name that when it's part of the display group causes the condition to be met.
+ * `double' must be an element's name and must be part of the display group.
+ * `attributes' is just an array of key-value pairs.
+ *
+ * You can also pass `placement' to control whether the doubled element is prepended or appended.
+ */
+class ElementDoubler extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * Return the display group's elements with an additional copy of an element being added if the condition is met
+ *
+ * @param string $content The HTML rendered so far
+ *
+ * @return string
+ */
+ public function render($content)
+ {
+ $group = $this->getElement();
+ if ($group->getElement($this->getOption('condition')) !== null) {
+ if ($this->getPlacement() === static::APPEND) {
+ return $content . $this->applyAttributes($group->getElement($this->getOption('double')))->render();
+ } else { // $this->getPlacement() === static::PREPEND
+ return $this->applyAttributes($group->getElement($this->getOption('double')))->render() . $content;
+ }
+ }
+
+ return $content;
+ }
+
+ /**
+ * Apply all element attributes
+ *
+ * @param Zend_Form_Element $element The element to apply the attributes to
+ *
+ * @return Zend_Form_Element
+ */
+ protected function applyAttributes(Zend_Form_Element $element)
+ {
+ $attributes = $this->getOption('attributes');
+ if ($attributes !== null) {
+ foreach ($attributes as $name => $value) {
+ $element->setAttrib($name, $value);
+ }
+ }
+
+ return $element;
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/FormDescriptions.php b/library/Icinga/Web/Form/Decorator/FormDescriptions.php
new file mode 100644
index 0000000..5bd5f6a
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/FormDescriptions.php
@@ -0,0 +1,76 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\Form;
+use Zend_Form_Decorator_Abstract;
+
+/**
+ * Decorator to add a list of descriptions at the top or bottom of a form
+ */
+class FormDescriptions extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * Render form descriptions
+ *
+ * @param string $content The html rendered so far
+ *
+ * @return ?string The updated html
+ */
+ public function render($content = '')
+ {
+ $form = $this->getElement();
+ if (! $form instanceof Form) {
+ return $content;
+ }
+
+ $view = $form->getView();
+ if ($view === null) {
+ return $content;
+ }
+
+ $descriptions = $this->recurseForm($form);
+ if (empty($descriptions)) {
+ return $content;
+ }
+
+ $html = '<div class="form-description">'
+ . Icinga::app()->getViewRenderer()->view->icon('info-circled', '', ['class' => 'form-description-icon'])
+ . '<ul class="form-description-list">';
+
+ foreach ($descriptions as $description) {
+ if (is_array($description)) {
+ list($description, $properties) = $description;
+ $html .= '<li' . $view->propertiesToString($properties) . '>' . $view->escape($description) . '</li>';
+ } else {
+ $html .= '<li>' . $view->escape($description) . '</li>';
+ }
+ }
+
+ switch ($this->getPlacement()) {
+ case self::APPEND:
+ return $content . $html . '</ul></div>';
+ case self::PREPEND:
+ return $html . '</ul></div>' . $content;
+ }
+ }
+
+ /**
+ * Recurse the given form and return the descriptions for it and all of its subforms
+ *
+ * @param Form $form The form to recurse
+ *
+ * @return array
+ */
+ protected function recurseForm(Form $form)
+ {
+ $descriptions = array($form->getDescriptions());
+ foreach ($form->getSubForms() as $subForm) {
+ $descriptions[] = $this->recurseForm($subForm);
+ }
+
+ return call_user_func_array('array_merge', $descriptions);
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/FormHints.php b/library/Icinga/Web/Form/Decorator/FormHints.php
new file mode 100644
index 0000000..2a0f193
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/FormHints.php
@@ -0,0 +1,142 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Zend_Form_Decorator_Abstract;
+use Icinga\Web\Form;
+
+/**
+ * Decorator to add a list of hints at the top or bottom of a form
+ *
+ * The hint for required form elements is automatically being handled.
+ */
+class FormHints extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * A list of element class names to be ignored when detecting which message to use to describe required elements
+ *
+ * @var array
+ */
+ protected $blacklist;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct($options = null)
+ {
+ parent::__construct($options);
+ $this->blacklist = array(
+ 'Zend_Form_Element_Hidden',
+ 'Zend_Form_Element_Submit',
+ 'Zend_Form_Element_Button',
+ 'Icinga\Web\Form\Element\Note',
+ 'Icinga\Web\Form\Element\Button',
+ 'Icinga\Web\Form\Element\CsrfCounterMeasure'
+ );
+ }
+
+ /**
+ * Render form hints
+ *
+ * @param string $content The html rendered so far
+ *
+ * @return ?string The updated html
+ */
+ public function render($content = '')
+ {
+ $form = $this->getElement();
+ if (! $form instanceof Form) {
+ return $content;
+ }
+
+ $view = $form->getView();
+ if ($view === null) {
+ return $content;
+ }
+
+ $hints = $this->recurseForm($form, $entirelyRequired);
+ if ($entirelyRequired !== null) {
+ $hints[] = sprintf(
+ $form->getView()->translate('%s Required field'),
+ $form->getRequiredCue()
+ );
+ }
+
+ if (empty($hints)) {
+ return $content;
+ }
+
+ $html = '<ul class="form-info">';
+ foreach ($hints as $hint) {
+ if (is_array($hint)) {
+ list($hint, $properties) = $hint;
+ $html .= '<li' . $view->propertiesToString($properties) . '>' . $view->escape($hint) . '</li>';
+ } else {
+ $html .= '<li>' . $view->escape($hint) . '</li>';
+ }
+ }
+
+ switch ($this->getPlacement()) {
+ case self::APPEND:
+ return $content . $html . '</ul>';
+ case self::PREPEND:
+ return $html . '</ul>' . $content;
+ }
+ }
+
+ /**
+ * Recurse the given form and return the hints for it and all of its subforms
+ *
+ * @param Form $form The form to recurse
+ * @param mixed $entirelyRequired Set by reference, true means all elements in the hierarchy are
+ * required, false only a partial subset and null none at all
+ * @param bool $elementsPassed Whether there were any elements passed during the recursion until now
+ *
+ * @return array
+ */
+ protected function recurseForm(Form $form, &$entirelyRequired = null, $elementsPassed = false)
+ {
+ $requiredLabels = array();
+ if ($form->getRequiredCue() !== null) {
+ $partiallyRequired = $partiallyOptional = false;
+ foreach ($form->getElements() as $element) {
+ if (! in_array($element->getType(), $this->blacklist)) {
+ if (! $element->isRequired()) {
+ $partiallyOptional = true;
+ if ($entirelyRequired) {
+ $entirelyRequired = false;
+ }
+ } else {
+ $partiallyRequired = true;
+ if (($label = $element->getDecorator('label')) !== false) {
+ $requiredLabels[] = $label;
+ }
+ }
+ }
+ }
+
+ if (! $elementsPassed) {
+ $elementsPassed = $partiallyRequired || $partiallyOptional;
+ if ($entirelyRequired === null && $partiallyRequired) {
+ $entirelyRequired = ! $partiallyOptional;
+ }
+ } elseif ($entirelyRequired === null && $partiallyRequired) {
+ $entirelyRequired = false;
+ }
+ }
+
+ $hints = array($form->getHints());
+ foreach ($form->getSubForms() as $subForm) {
+ $hints[] = $this->recurseForm($subForm, $entirelyRequired, $elementsPassed);
+ }
+
+ if ($entirelyRequired) {
+ foreach ($requiredLabels as $label) {
+ $label->setRequiredSuffix('');
+ }
+ }
+
+ return call_user_func_array('array_merge', $hints);
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/FormNotifications.php b/library/Icinga/Web/Form/Decorator/FormNotifications.php
new file mode 100644
index 0000000..87d12aa
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/FormNotifications.php
@@ -0,0 +1,125 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Form;
+use Zend_Form_Decorator_Abstract;
+
+/**
+ * Decorator to add a list of notifications at the top or bottom of a form
+ */
+class FormNotifications extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * Render form notifications
+ *
+ * @param string $content The html rendered so far
+ *
+ * @return ?string The updated html
+ */
+ public function render($content = '')
+ {
+ $form = $this->getElement();
+ if (! $form instanceof Form) {
+ return $content;
+ }
+
+ $view = $form->getView();
+ if ($view === null) {
+ return $content;
+ }
+
+ $notifications = $this->recurseForm($form);
+ if (empty($notifications)) {
+ return $content;
+ }
+
+ $html = '<ul class="form-notification-list">';
+ foreach (array(Form::NOTIFICATION_ERROR, Form::NOTIFICATION_WARNING, Form::NOTIFICATION_INFO) as $type) {
+ if (isset($notifications[$type])) {
+ $html .= '<li><ul class="notification-' . $this->getNotificationTypeName($type) . '">';
+ foreach ($notifications[$type] as $message) {
+ if (is_array($message)) {
+ list($message, $properties) = $message;
+ $html .= '<li' . $view->propertiesToString($properties) . '>'
+ . $view->escape($message)
+ . '</li>';
+ } else {
+ $html .= '<li>' . $view->escape($message) . '</li>';
+ }
+ }
+
+ $html .= '</ul></li>';
+ }
+ }
+
+ if (isset($notifications[Form::NOTIFICATION_ERROR])) {
+ $icon = 'cancel';
+ $class = 'error';
+ } elseif (isset($notifications[Form::NOTIFICATION_WARNING])) {
+ $icon = 'warning-empty';
+ $class = 'warning';
+ } else {
+ $icon = 'info';
+ $class = 'info';
+ }
+
+ $html = "<div class=\"form-notifications $class\">"
+ . Icinga::app()->getViewRenderer()->view->icon($icon, '', ['class' => 'form-notification-icon'])
+ . $html;
+
+ switch ($this->getPlacement()) {
+ case self::APPEND:
+ return $content . $html . '</ul></div>';
+ case self::PREPEND:
+ return $html . '</ul></div>' . $content;
+ }
+ }
+
+ /**
+ * Recurse the given form and return the notifications for it and all of its subforms
+ *
+ * @param Form $form The form to recurse
+ *
+ * @return array
+ */
+ protected function recurseForm(Form $form)
+ {
+ $notifications = $form->getNotifications();
+ foreach ($form->getSubForms() as $subForm) {
+ foreach ($this->recurseForm($subForm) as $type => $messages) {
+ foreach ($messages as $message) {
+ $notifications[$type][] = $message;
+ }
+ }
+ }
+
+ return $notifications;
+ }
+
+ /**
+ * Return the name for the given notification type
+ *
+ * @param int $type
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case the given type is invalid
+ */
+ protected function getNotificationTypeName($type)
+ {
+ switch ($type) {
+ case Form::NOTIFICATION_ERROR:
+ return 'error';
+ case Form::NOTIFICATION_WARNING:
+ return 'warning';
+ case Form::NOTIFICATION_INFO:
+ return 'info';
+ default:
+ throw new ProgrammingError('Invalid notification type "%s" provided', $type);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/Help.php b/library/Icinga/Web/Form/Decorator/Help.php
new file mode 100644
index 0000000..9e30e86
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/Help.php
@@ -0,0 +1,113 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Zend_Form_Element;
+use Zend_Form_Decorator_Abstract;
+use Icinga\Application\Icinga;
+use Icinga\Web\View;
+
+/**
+ * Decorator to add helptext to a form element
+ */
+class Help extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * Whether a hidden <span> should be created to describe the decorated form element
+ *
+ * @var bool
+ */
+ protected $accessible = false;
+
+ /**
+ * The id used to identify the description associated with the decorated form element
+ *
+ * @var string
+ */
+ protected $descriptionId;
+
+ /**
+ * Set whether a hidden <span> should be created to describe the decorated form element
+ *
+ * @param bool $state
+ *
+ * @return Help
+ */
+ public function setAccessible($state = true)
+ {
+ $this->accessible = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return the id used to identify the description associated with the decorated element
+ *
+ * @param Zend_Form_Element $element The element for which to generate a id
+ *
+ * @return string
+ */
+ public function getDescriptionId(Zend_Form_Element $element = null)
+ {
+ if ($this->descriptionId === null) {
+ $element = $element ?: $this->getElement();
+ $this->descriptionId = 'desc_' . $element->getId();
+ }
+
+ return $this->descriptionId;
+ }
+
+ /**
+ * Return the current view
+ *
+ * @return View
+ */
+ protected function getView()
+ {
+ return Icinga::app()->getViewRenderer()->view;
+ }
+
+ /**
+ * Add a help icon to the left of an element
+ *
+ * @param string $content The html rendered so far
+ *
+ * @return ?string The updated html
+ */
+ public function render($content = '')
+ {
+ $element = $this->getElement();
+ $description = $element->getDescription();
+ $requirement = $element->getAttrib('requirement');
+ unset($element->requirement);
+
+ $helpContent = '';
+ if ($description || $requirement) {
+ if ($this->accessible) {
+ $helpContent = '<span id="'
+ . $this->getDescriptionId()
+ . '" class="sr-only">'
+ . $description
+ . ($description && $requirement ? ' ' : '')
+ . $requirement
+ . '</span>';
+ }
+
+ $helpContent = $this->getView()->icon(
+ 'info-circled',
+ $description . ($description && $requirement ? ' ' : '') . $requirement,
+ array(
+ 'class' => 'control-info',
+ 'aria-hidden' => $this->accessible ? 'true' : 'false'
+ )
+ ) . $helpContent;
+ }
+
+ switch ($this->getPlacement()) {
+ case self::APPEND:
+ return $content . $helpContent;
+ case self::PREPEND:
+ return $helpContent . $content;
+ }
+ }
+}
diff --git a/library/Icinga/Web/Form/Decorator/Spinner.php b/library/Icinga/Web/Form/Decorator/Spinner.php
new file mode 100644
index 0000000..09a3ae9
--- /dev/null
+++ b/library/Icinga/Web/Form/Decorator/Spinner.php
@@ -0,0 +1,48 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Decorator;
+
+use Zend_Form_Decorator_Abstract;
+use Icinga\Application\Icinga;
+use Icinga\Web\View;
+
+/**
+ * Decorator to add a spinner next to an element
+ */
+class Spinner extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * Return the current view
+ *
+ * @return View
+ */
+ protected function getView()
+ {
+ return Icinga::app()->getViewRenderer()->view;
+ }
+
+ /**
+ * Add a spinner icon to a form element
+ *
+ * @param string $content The html rendered so far
+ *
+ * @return ?string The updated html
+ */
+ public function render($content = '')
+ {
+ $spinner = '<div '
+ . ($this->getOption('id') !== null ? ' id="' . $this->getOption('id') . '"' : '')
+ . 'class="spinner ' . ($this->getOption('class') ?: '') . '"'
+ . '>'
+ . $this->getView()->icon('spin6')
+ . '</div>';
+
+ switch ($this->getPlacement()) {
+ case self::APPEND:
+ return $content . $spinner;
+ case self::PREPEND:
+ return $spinner . $content;
+ }
+ }
+}
diff --git a/library/Icinga/Web/Form/Element/Button.php b/library/Icinga/Web/Form/Element/Button.php
new file mode 100644
index 0000000..307247e
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/Button.php
@@ -0,0 +1,81 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use Icinga\Web\Request;
+use Icinga\Application\Icinga;
+use Icinga\Web\Form\FormElement;
+use Zend_Config;
+
+/**
+ * A button
+ */
+class Button extends FormElement
+{
+ /**
+ * Use formButton view helper by default
+ *
+ * @var string
+ */
+ public $helper = 'formButton';
+
+ /**
+ * Constructor
+ *
+ * @param string|array|Zend_Config $spec Element name or configuration
+ * @param string|array|Zend_Config $options Element value or configuration
+ */
+ public function __construct($spec, $options = null)
+ {
+ if (is_string($spec) && ((null !== $options) && is_string($options))) {
+ $options = array('label' => $options);
+ }
+
+ if (!isset($options['ignore'])) {
+ $options['ignore'] = true;
+ }
+
+ parent::__construct($spec, $options);
+
+ if ($label = $this->getLabel()) {
+ // Necessary to get the label shown on the generated HTML
+ $this->content = $label;
+ }
+ }
+
+ /**
+ * Validate element value (pseudo)
+ *
+ * There is no need to reset the value
+ *
+ * @param mixed $value Is always ignored
+ * @param mixed $context Is always ignored
+ *
+ * @return bool Returns always TRUE
+ */
+ public function isValid($value, $context = null)
+ {
+ return true;
+ }
+
+ /**
+ * Has this button been selected?
+ *
+ * @return bool
+ */
+ public function isChecked()
+ {
+ return $this->getRequest()->getParam($this->getName()) === $this->getValue();
+ }
+
+ /**
+ * Return the current request
+ *
+ * @return Request
+ */
+ protected function getRequest()
+ {
+ return Icinga::app()->getRequest();
+ }
+}
diff --git a/library/Icinga/Web/Form/Element/Checkbox.php b/library/Icinga/Web/Form/Element/Checkbox.php
new file mode 100644
index 0000000..d4499a0
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/Checkbox.php
@@ -0,0 +1,9 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+class Checkbox extends \Zend_Form_Element_Checkbox
+{
+ public $helper = 'icingaCheckbox';
+}
diff --git a/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php b/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php
new file mode 100644
index 0000000..c59e1f9
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php
@@ -0,0 +1,99 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use Icinga\Web\Session;
+use Icinga\Web\Form\FormElement;
+use Icinga\Web\Form\InvalidCSRFTokenException;
+
+/**
+ * CSRF counter measure element
+ *
+ * You must not set a value to successfully use this element, just give it a name and you're good to go.
+ */
+class CsrfCounterMeasure extends FormElement
+{
+ /**
+ * Default form view helper to use for rendering
+ *
+ * @var string
+ */
+ public $helper = 'formHidden';
+
+ /**
+ * Counter measure element is required
+ *
+ * @var bool
+ */
+ protected $_ignore = true;
+
+ /**
+ * Ignore element when retrieving values at form level
+ *
+ * @var bool
+ */
+ protected $_required = true;
+
+ /**
+ * Initialize this form element
+ */
+ public function init()
+ {
+ $this->setDecorators(['ViewHelper']);
+ $this->setValue($this->generateCsrfToken());
+ }
+
+ /**
+ * Check whether $value is a valid CSRF token
+ *
+ * @param string $value The value to check
+ * @param mixed $context Context to use
+ *
+ * @return bool True, in case the CSRF token is valid
+ *
+ * @throws InvalidCSRFTokenException In case the CSRF token is not valid
+ */
+ public function isValid($value, $context = null)
+ {
+ if (parent::isValid($value, $context) && $this->isValidCsrfToken($value)) {
+ return true;
+ }
+
+ throw new InvalidCSRFTokenException();
+ }
+
+ /**
+ * Check whether the given value is a valid CSRF token for the current session
+ *
+ * @param string $token The CSRF token
+ *
+ * @return bool
+ */
+ protected function isValidCsrfToken($token)
+ {
+ if (strpos($token, '|') === false) {
+ return false;
+ }
+
+ list($seed, $hash) = explode('|', $token);
+
+ if (false === is_numeric($seed)) {
+ return false;
+ }
+
+ return $hash === hash('sha256', Session::getSession()->getId() . $seed);
+ }
+
+ /**
+ * Generate a new (seed, token) pair
+ *
+ * @return string
+ */
+ protected function generateCsrfToken()
+ {
+ $seed = mt_rand();
+ $hash = hash('sha256', Session::getSession()->getId() . $seed);
+ return sprintf('%s|%s', $seed, $hash);
+ }
+}
diff --git a/library/Icinga/Web/Form/Element/Date.php b/library/Icinga/Web/Form/Element/Date.php
new file mode 100644
index 0000000..8e0985c
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/Date.php
@@ -0,0 +1,19 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use Icinga\Web\Form\FormElement;
+
+/**
+ * A date input control
+ */
+class Date extends FormElement
+{
+ /**
+ * Form view helper to use for rendering
+ *
+ * @var string
+ */
+ public $helper = 'formDate';
+}
diff --git a/library/Icinga/Web/Form/Element/DateTimePicker.php b/library/Icinga/Web/Form/Element/DateTimePicker.php
new file mode 100644
index 0000000..284a744
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/DateTimePicker.php
@@ -0,0 +1,80 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use DateTime;
+use Icinga\Web\Form\FormElement;
+use Icinga\Web\Form\Validator\DateTimeValidator;
+
+/**
+ * A date-and-time input control
+ */
+class DateTimePicker extends FormElement
+{
+ /**
+ * Form view helper to use for rendering
+ *
+ * @var string
+ */
+ public $helper = 'formDateTime';
+
+ /**
+ * @var bool
+ */
+ protected $local = true;
+
+ /**
+ * (non-PHPDoc)
+ * @see Zend_Form_Element::init() For the method documentation.
+ */
+ public function init()
+ {
+ $this->addValidator(
+ new DateTimeValidator($this->local),
+ true // true for breaking the validator chain on failure
+ );
+ }
+
+ /**
+ * Get the expected date and time format of any user input
+ *
+ * @return string
+ */
+ public function getFormat()
+ {
+ return $this->local ? 'Y-m-d\TH:i:s' : DateTime::RFC3339;
+ }
+
+ /**
+ * Is the date and time valid?
+ *
+ * @param string|DateTime $value
+ * @param mixed $context
+ *
+ * @return bool
+ */
+ public function isValid($value, $context = null)
+ {
+ if (is_scalar($value) && $value !== '' && ! preg_match('/\D/', $value)) {
+ $dateTime = new DateTime();
+ $value = $dateTime->setTimestamp($value)->format($this->getFormat());
+ }
+
+ if (! parent::isValid($value, $context)) {
+ return false;
+ }
+
+ if (! $value instanceof DateTime) {
+ $format = $this->getFormat();
+ $dateTime = DateTime::createFromFormat($format, $value);
+ if ($dateTime === false) {
+ $dateTime = DateTime::createFromFormat(substr($format, 0, strrpos($format, ':')), $value);
+ }
+
+ $this->setValue($dateTime);
+ }
+
+ return true;
+ }
+}
diff --git a/library/Icinga/Web/Form/Element/Note.php b/library/Icinga/Web/Form/Element/Note.php
new file mode 100644
index 0000000..9569dee
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/Note.php
@@ -0,0 +1,55 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use Icinga\Web\Form\FormElement;
+
+/**
+ * A note
+ */
+class Note extends FormElement
+{
+ /**
+ * Form view helper to use for rendering
+ *
+ * @var string
+ */
+ public $helper = 'formNote';
+
+ /**
+ * Ignore element when retrieving values at form level
+ *
+ * @var bool
+ */
+ protected $_ignore = true;
+
+ /**
+ * (non-PHPDoc)
+ * @see Zend_Form_Element::init() For the method documentation.
+ */
+ public function init()
+ {
+ if (count($this->getDecorators()) === 0) {
+ $this->setDecorators(array(
+ 'ViewHelper',
+ array(
+ 'HtmlTag',
+ array('tag' => 'p')
+ )
+ ));
+ }
+ }
+
+ /**
+ * Validate element value (pseudo)
+ *
+ * @param mixed $value Ignored
+ *
+ * @return bool Always true
+ */
+ public function isValid($value, $context = null)
+ {
+ return true;
+ }
+}
diff --git a/library/Icinga/Web/Form/Element/Number.php b/library/Icinga/Web/Form/Element/Number.php
new file mode 100644
index 0000000..afbd07d
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/Number.php
@@ -0,0 +1,144 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use Icinga\Web\Form\FormElement;
+
+/**
+ * A number input control
+ */
+class Number extends FormElement
+{
+ /**
+ * Form view helper to use for rendering
+ *
+ * @var string
+ */
+ public $helper = 'formNumber';
+
+ /**
+ * The expected lower bound for the element’s value
+ *
+ * @var float|null
+ */
+ protected $min;
+
+ /**
+ * The expected upper bound for the element’s
+ *
+ * @var float|null
+ */
+ protected $max;
+
+ /**
+ * The value granularity of the element’s value
+ *
+ * Normally, number input controls are limited to an accuracy of integer values.
+ *
+ * @var float|string|null
+ */
+ protected $step;
+
+ /**
+ * (non-PHPDoc)
+ * @see \Zend_Form_Element::init() For the method documentation.
+ */
+ public function init()
+ {
+ if ($this->min !== null || $this->max !== null) {
+ $this->addValidator('Between', true, array(
+ 'min' => $this->min === null ? -INF : $this->min,
+ 'max' => $this->max === null ? INF : $this->max,
+ 'inclusive' => true
+ ));
+ }
+ }
+
+ /**
+ * Set the expected lower bound for the element’s value
+ *
+ * @param float $min
+ *
+ * @return $this
+ */
+ public function setMin($min)
+ {
+ $this->min = (float) $min;
+ return $this;
+ }
+
+ /**
+ * Get the expected lower bound for the element’s value
+ *
+ * @return float|null
+ */
+ public function getMin()
+ {
+ return $this->min;
+ }
+
+ /**
+ * Set the expected upper bound for the element’s value
+ *
+ * @param float $max
+ *
+ * @return $this
+ */
+ public function setMax($max)
+ {
+ $this->max = (float) $max;
+ return $this;
+ }
+
+ /**
+ * Get the expected upper bound for the element’s value
+ *
+ * @return float|null
+ */
+ public function getMax()
+ {
+ return $this->max;
+ }
+
+ /**
+ * Set the value granularity of the element’s value
+ *
+ * @param float|string $step
+ *
+ * @return $this
+ */
+ public function setStep($step)
+ {
+ if ($step !== 'any') {
+ $step = (float) $step;
+ }
+ $this->step = $step;
+ return $this;
+ }
+
+ /**
+ * Get the value granularity of the element’s value
+ *
+ * @return float|string|null
+ */
+ public function getStep()
+ {
+ return $this->step;
+ }
+
+ /**
+ * (non-PHPDoc)
+ * @see \Zend_Form_Element::isValid() For the method documentation.
+ */
+ public function isValid($value, $context = null)
+ {
+ $this->setValue($value);
+ $value = $this->getValue();
+ if ($value !== null && $value !== '' && ! is_numeric($value)) {
+ $this->addError(sprintf(t('\'%s\' is not a valid number'), $value));
+ return false;
+ }
+ return parent::isValid($value, $context);
+ }
+}
diff --git a/library/Icinga/Web/Form/Element/Textarea.php b/library/Icinga/Web/Form/Element/Textarea.php
new file mode 100644
index 0000000..119cd56
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/Textarea.php
@@ -0,0 +1,20 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use Icinga\Web\Form\FormElement;
+
+class Textarea extends FormElement
+{
+ public $helper = 'formTextarea';
+
+ public function __construct($spec, $options = null)
+ {
+ parent::__construct($spec, $options);
+
+ if ($this->getAttrib('rows') === null) {
+ $this->setAttrib('rows', 3);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Form/Element/Time.php b/library/Icinga/Web/Form/Element/Time.php
new file mode 100644
index 0000000..4b76a33
--- /dev/null
+++ b/library/Icinga/Web/Form/Element/Time.php
@@ -0,0 +1,19 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Element;
+
+use Icinga\Web\Form\FormElement;
+
+/**
+ * A time input control
+ */
+class Time extends FormElement
+{
+ /**
+ * Form view helper to use for rendering
+ *
+ * @var string
+ */
+ public $helper = 'formTime';
+}
diff --git a/library/Icinga/Web/Form/ErrorLabeller.php b/library/Icinga/Web/Form/ErrorLabeller.php
new file mode 100644
index 0000000..3f822d5
--- /dev/null
+++ b/library/Icinga/Web/Form/ErrorLabeller.php
@@ -0,0 +1,71 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form;
+
+use BadMethodCallException;
+use Zend_Translate_Adapter;
+use Zend_Validate_NotEmpty;
+use Zend_Validate_File_MimeType;
+use Icinga\Web\Form\Validator\DateTimeValidator;
+use Icinga\Web\Form\Validator\ReadablePathValidator;
+use Icinga\Web\Form\Validator\WritablePathValidator;
+
+class ErrorLabeller extends Zend_Translate_Adapter
+{
+ protected $messages;
+
+ public function __construct($options = array())
+ {
+ if (! isset($options['element'])) {
+ throw new BadMethodCallException('Option "element" is missing');
+ }
+
+ $this->messages = $this->createMessages($options['element']);
+ }
+
+ public function isTranslated($messageId, $original = false, $locale = null)
+ {
+ return array_key_exists($messageId, $this->messages);
+ }
+
+ public function translate($messageId, $locale = null)
+ {
+ if (array_key_exists($messageId, $this->messages)) {
+ return $this->messages[$messageId];
+ }
+
+ return $messageId;
+ }
+
+ protected function createMessages($element)
+ {
+ $label = $element->getLabel() ?: $element->getName();
+
+ return array(
+ Zend_Validate_NotEmpty::IS_EMPTY => sprintf(t('%s is required and must not be empty'), $label),
+ Zend_Validate_File_MimeType::FALSE_TYPE => sprintf(
+ t('%s (%%value%%) has a false MIME type of "%%type%%"'),
+ $label
+ ),
+ Zend_Validate_File_MimeType::NOT_DETECTED => sprintf(t('%s (%%value%%) has no MIME type'), $label),
+ WritablePathValidator::NOT_WRITABLE => sprintf(t('%s is not writable', 'config.path'), $label),
+ WritablePathValidator::DOES_NOT_EXIST => sprintf(t('%s does not exist', 'config.path'), $label),
+ ReadablePathValidator::NOT_READABLE => sprintf(t('%s is not readable', 'config.path'), $label),
+ DateTimeValidator::INVALID_DATETIME_FORMAT => sprintf(
+ t('%s not in the expected format: %%value%%'),
+ $label
+ )
+ );
+ }
+
+ protected function _loadTranslationData($data, $locale, array $options = array())
+ {
+ // nonsense, required as being abstract otherwise...
+ }
+
+ public function toString()
+ {
+ return 'ErrorLabeller'; // nonsense, required as being abstract otherwise...
+ }
+}
diff --git a/library/Icinga/Web/Form/FormElement.php b/library/Icinga/Web/Form/FormElement.php
new file mode 100644
index 0000000..766d916
--- /dev/null
+++ b/library/Icinga/Web/Form/FormElement.php
@@ -0,0 +1,61 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form;
+
+use Zend_Form_Element;
+use Icinga\Web\Form;
+
+/**
+ * Base class for Icinga Web 2 form elements
+ */
+class FormElement extends Zend_Form_Element
+{
+ /**
+ * Whether loading default decorators is disabled
+ *
+ * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set this
+ * property to false.
+ *
+ * @var null|bool
+ */
+ protected $_disableLoadDefaultDecorators;
+
+ /**
+ * Whether loading default decorators is disabled
+ *
+ * @return bool
+ */
+ public function loadDefaultDecoratorsIsDisabled()
+ {
+ return $this->_disableLoadDefaultDecorators === true;
+ }
+
+ /**
+ * Load default decorators
+ *
+ * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set
+ * FormElement::$_disableLoadDefaultDecorators to false.
+ *
+ * @return $this
+ * @see Form::$defaultElementDecorators For Icinga Web 2's default element decorators.
+ */
+ public function loadDefaultDecorators()
+ {
+ if ($this->loadDefaultDecoratorsIsDisabled()) {
+ return $this;
+ }
+
+ if (! isset($this->_disableLoadDefaultDecorators)) {
+ $decorators = $this->getDecorators();
+ if (empty($decorators)) {
+ // Load Icinga Web 2's default element decorators
+ $this->addDecorators(Form::$defaultElementDecorators);
+ }
+ } else {
+ // Load Zend's default decorators
+ parent::loadDefaultDecorators();
+ }
+ return $this;
+ }
+}
diff --git a/library/Icinga/Web/Form/InvalidCSRFTokenException.php b/library/Icinga/Web/Form/InvalidCSRFTokenException.php
new file mode 100644
index 0000000..d0eb68a
--- /dev/null
+++ b/library/Icinga/Web/Form/InvalidCSRFTokenException.php
@@ -0,0 +1,11 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form;
+
+/**
+ * Exceptions for invalid form tokens
+ */
+class InvalidCSRFTokenException extends \Exception
+{
+}
diff --git a/library/Icinga/Web/Form/Validator/DateFormatValidator.php b/library/Icinga/Web/Form/Validator/DateFormatValidator.php
new file mode 100644
index 0000000..eacb29c
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/DateFormatValidator.php
@@ -0,0 +1,61 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use Zend_Validate_Abstract;
+
+/**
+ * Validator that checks if a textfield contains a correct date format
+ */
+class DateFormatValidator extends Zend_Validate_Abstract
+{
+
+ /**
+ * Valid date characters according to @see http://www.php.net/manual/en/function.date.php
+ *
+ * @var array
+ *
+ * @see http://www.php.net/manual/en/function.date.php
+ */
+ private $validChars =
+ array('d', 'D', 'j', 'l', 'N', 'S', 'w', 'z', 'W', 'F', 'm', 'M', 'n', 't', 'L', 'o', 'Y', 'y');
+
+ /**
+ * List of sensible time separators
+ *
+ * @var array
+ */
+ private $validSeparators = array(' ', ':', '-', '/', ';', ',', '.');
+
+ /**
+ * Error templates
+ *
+ * @var array
+ *
+ * @see Zend_Validate_Abstract::$_messageTemplates
+ */
+ protected $_messageTemplates = array(
+ 'INVALID_CHARACTERS' => 'Invalid date format'
+ );
+
+ /**
+ * Validate the input value
+ *
+ * @param string $value The format string to validate
+ * @param null $context The form context (ignored)
+ *
+ * @return bool True when the input is valid, otherwise false
+ *
+ * @see Zend_Validate_Abstract::isValid()
+ */
+ public function isValid($value, $context = null)
+ {
+ $rest = trim($value, join(' ', array_merge($this->validChars, $this->validSeparators)));
+ if (strlen($rest) > 0) {
+ $this->_error('INVALID_CHARACTERS');
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/library/Icinga/Web/Form/Validator/DateTimeValidator.php b/library/Icinga/Web/Form/Validator/DateTimeValidator.php
new file mode 100644
index 0000000..5ef327d
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/DateTimeValidator.php
@@ -0,0 +1,77 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use DateTime;
+use Zend_Validate_Abstract;
+
+/**
+ * Validator for date-and-time input controls
+ *
+ * @see \Icinga\Web\Form\Element\DateTimePicker For the date-and-time input control.
+ */
+class DateTimeValidator extends Zend_Validate_Abstract
+{
+ const INVALID_DATETIME_TYPE = 'invalidDateTimeType';
+ const INVALID_DATETIME_FORMAT = 'invalidDateTimeFormat';
+
+ /**
+ * The messages to write on differen error states
+ *
+ * @var array
+ *
+ * @see Zend_Validate_Abstract::$_messageTemplates‚
+ */
+ protected $_messageTemplates = array(
+ self::INVALID_DATETIME_TYPE => 'Invalid type given. Instance of DateTime or date/time string expected',
+ self::INVALID_DATETIME_FORMAT => 'Date/time string not in the expected format: %value%'
+ );
+
+ protected $local;
+
+ /**
+ * Create a new date-and-time input control validator
+ *
+ * @param bool $local
+ */
+ public function __construct($local)
+ {
+ $this->local = (bool) $local;
+ }
+
+ /**
+ * Is the date and time valid?
+ *
+ * @param string|DateTime $value
+ * @param mixed $context
+ *
+ * @return bool
+ *
+ * @see \Zend_Validate_Interface::isValid()
+ */
+ public function isValid($value, $context = null)
+ {
+ if (! $value instanceof DateTime && ! is_string($value)) {
+ $this->_error(self::INVALID_DATETIME_TYPE);
+ return false;
+ }
+
+ if (! $value instanceof DateTime) {
+ $format = $baseFormat = $this->local === true ? 'Y-m-d\TH:i:s' : DateTime::RFC3339;
+ $dateTime = DateTime::createFromFormat($format, $value);
+
+ if ($dateTime === false) {
+ $format = substr($format, 0, strrpos($format, ':'));
+ $dateTime = DateTime::createFromFormat($format, $value);
+ }
+
+ if ($dateTime === false || $dateTime->format($format) !== $value) {
+ $this->_error(self::INVALID_DATETIME_FORMAT, $baseFormat);
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/library/Icinga/Web/Form/Validator/InArray.php b/library/Icinga/Web/Form/Validator/InArray.php
new file mode 100644
index 0000000..5d3925e
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/InArray.php
@@ -0,0 +1,28 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use Zend_Validate_InArray;
+use Icinga\Util\StringHelper;
+
+class InArray extends Zend_Validate_InArray
+{
+ protected function _error($messageKey, $value = null)
+ {
+ if ($messageKey === static::NOT_IN_ARRAY) {
+ $matches = StringHelper::findSimilar($this->_value, $this->_haystack);
+ if (empty($matches)) {
+ $this->_messages[$messageKey] = sprintf(t('"%s" is not in the list of allowed values.'), $this->_value);
+ } else {
+ $this->_messages[$messageKey] = sprintf(
+ t('"%s" is not in the list of allowed values. Did you mean one of the following?: %s'),
+ $this->_value,
+ implode(', ', $matches)
+ );
+ }
+ } else {
+ parent::_error($messageKey, $value);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Form/Validator/InternalUrlValidator.php b/library/Icinga/Web/Form/Validator/InternalUrlValidator.php
new file mode 100644
index 0000000..f936bb5
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/InternalUrlValidator.php
@@ -0,0 +1,41 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use Icinga\Application\Icinga;
+use Zend_Validate_Abstract;
+use Icinga\Web\Url;
+
+/**
+ * Validator that checks whether a textfield doesn't contain an external URL
+ */
+class InternalUrlValidator extends Zend_Validate_Abstract
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function isValid($value)
+ {
+ $url = Url::fromPath($value);
+ if ($url->getRelativeUrl() === '' || $url->isExternal()) {
+ $this->_error('IS_EXTERNAL');
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function _error($messageKey, $value = null)
+ {
+ if ($messageKey === 'IS_EXTERNAL') {
+ $this->_messages[$messageKey] = t('The url must not be external.');
+ } else {
+ parent::_error($messageKey, $value);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Form/Validator/ReadablePathValidator.php b/library/Icinga/Web/Form/Validator/ReadablePathValidator.php
new file mode 100644
index 0000000..826421c
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/ReadablePathValidator.php
@@ -0,0 +1,53 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use Zend_Validate_Abstract;
+
+/**
+ * Validator that interprets the value as a filepath and checks if it's readable
+ *
+ * This validator should be preferred due to Zend_Validate_File_Exists is
+ * getting confused if there is another element in the form called `name'.
+ */
+class ReadablePathValidator extends Zend_Validate_Abstract
+{
+ const NOT_READABLE = 'notReadable';
+ const DOES_NOT_EXIST = 'doesNotExist';
+
+ /**
+ * The messages to write on different error states
+ *
+ * @var array
+ *
+ * @see Zend_Validate_Abstract::$_messageTemplates‚
+ */
+ protected $_messageTemplates = array(
+ self::NOT_READABLE => 'Path is not readable',
+ self::DOES_NOT_EXIST => 'Path does not exist'
+ );
+
+ /**
+ * Check whether the given value is a readable filepath
+ *
+ * @param string $value The value submitted in the form
+ * @param mixed $context The context of the form
+ *
+ * @return bool Whether the value was successfully validated
+ */
+ public function isValid($value, $context = null)
+ {
+ if (false === file_exists($value)) {
+ $this->_error(self::DOES_NOT_EXIST);
+ return false;
+ }
+
+ if (false === is_readable($value)) {
+ $this->_error(self::NOT_READABLE);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/library/Icinga/Web/Form/Validator/TimeFormatValidator.php b/library/Icinga/Web/Form/Validator/TimeFormatValidator.php
new file mode 100644
index 0000000..9c1c99a
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/TimeFormatValidator.php
@@ -0,0 +1,58 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use Zend_Validate_Abstract;
+
+/**
+ * Validator that checks if a textfield contains a correct time format
+ */
+class TimeFormatValidator extends Zend_Validate_Abstract
+{
+
+ /**
+ * Valid time characters according to @see http://www.php.net/manual/en/function.date.php
+ *
+ * @var array
+ * @see http://www.php.net/manual/en/function.date.php
+ */
+ private $validChars = array('a', 'A', 'B', 'g', 'G', 'h', 'H', 'i', 's', 'u');
+
+ /**
+ * List of sensible time separators
+ *
+ * @var array
+ */
+ private $validSeparators = array(' ', ':', '-', '/', ';', ',', '.');
+
+ /**
+ * Error templates
+ *
+ * @var array
+ * @see Zend_Validate_Abstract::$_messageTemplates
+ */
+ protected $_messageTemplates = array(
+ 'INVALID_CHARACTERS' => 'Invalid time format'
+ );
+
+ /**
+ * Validate the input value
+ *
+ * @param string $value The format string to validate
+ * @param null $context The form context (ignored)
+ *
+ * @return bool True when the input is valid, otherwise false
+ *
+ * @see Zend_Validate_Abstract::isValid()
+ */
+ public function isValid($value, $context = null)
+ {
+ $rest = trim($value, join(' ', array_merge($this->validChars, $this->validSeparators)));
+ if (strlen($rest) > 0) {
+ $this->_error('INVALID_CHARACTERS');
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/library/Icinga/Web/Form/Validator/UrlValidator.php b/library/Icinga/Web/Form/Validator/UrlValidator.php
new file mode 100644
index 0000000..b1b578f
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/UrlValidator.php
@@ -0,0 +1,40 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use Zend_Validate_Abstract;
+
+/**
+ * Validator that checks whether a textfield doesn't contain raw double quotes
+ */
+class UrlValidator extends Zend_Validate_Abstract
+{
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ $this->_messageTemplates = array('HAS_QUOTES' => t(
+ 'The url must not contain raw double quotes. If you really need double quotes, use %22 instead.'
+ ));
+ }
+
+ /**
+ * Validate the input value
+ *
+ * @param string $value The string to validate
+ *
+ * @return bool true if and only if the input is valid, otherwise false
+ *
+ * @see Zend_Validate_Abstract::isValid()
+ */
+ public function isValid($value)
+ {
+ $hasQuotes = false === strpos($value, '"');
+ if (! $hasQuotes) {
+ $this->_error('HAS_QUOTES');
+ }
+ return $hasQuotes;
+ }
+}
diff --git a/library/Icinga/Web/Form/Validator/WritablePathValidator.php b/library/Icinga/Web/Form/Validator/WritablePathValidator.php
new file mode 100644
index 0000000..76efb58
--- /dev/null
+++ b/library/Icinga/Web/Form/Validator/WritablePathValidator.php
@@ -0,0 +1,72 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Form\Validator;
+
+use Zend_Validate_Abstract;
+
+/**
+ * Validator that interprets the value as a path and checks if it's writable
+ */
+class WritablePathValidator extends Zend_Validate_Abstract
+{
+ const NOT_WRITABLE = 'notWritable';
+ const DOES_NOT_EXIST = 'doesNotExist';
+
+ /**
+ * The messages to write on differen error states
+ *
+ * @var array
+ *
+ * @see Zend_Validate_Abstract::$_messageTemplates‚
+ */
+ protected $_messageTemplates = array(
+ self::NOT_WRITABLE => 'Path is not writable',
+ self::DOES_NOT_EXIST => 'Path does not exist'
+ );
+
+ /**
+ * When true, the file or directory must exist
+ *
+ * @var bool
+ */
+ private $requireExistence = false;
+
+ /**
+ * Set this validator to require the target file to exist
+ */
+ public function setRequireExistence()
+ {
+ $this->requireExistence = true;
+ }
+
+ /**
+ * Check whether the given value is writable path
+ *
+ * @param string $value The value submitted in the form
+ * @param mixed $context The context of the form
+ *
+ * @return bool True when validation worked, otherwise false
+ *
+ * @see Zend_Validate_Abstract::isValid()
+ */
+ public function isValid($value, $context = null)
+ {
+ $value = (string) $value;
+
+ $this->_setValue($value);
+ if ($this->requireExistence && !file_exists($value)) {
+ $this->_error(self::DOES_NOT_EXIST);
+ return false;
+ }
+
+ if ((file_exists($value) && is_writable($value)) ||
+ (is_dir(dirname($value)) && is_writable(dirname($value)))
+ ) {
+ return true;
+ }
+
+ $this->_error(self::NOT_WRITABLE);
+ return false;
+ }
+}
diff --git a/library/Icinga/Web/Helper/CookieHelper.php b/library/Icinga/Web/Helper/CookieHelper.php
new file mode 100644
index 0000000..cc7c448
--- /dev/null
+++ b/library/Icinga/Web/Helper/CookieHelper.php
@@ -0,0 +1,81 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Helper;
+
+use Icinga\Web\Request;
+
+/**
+ * Helper Class Cookie
+ */
+class CookieHelper
+{
+ /**
+ * The name of the control cookie
+ */
+ const CHECK_COOKIE = '_chc';
+
+ /**
+ * The request
+ *
+ * @var Request
+ */
+ protected $request;
+
+ /**
+ * Create a new cookie
+ *
+ * @param Request $request
+ */
+ public function __construct(Request $request)
+ {
+ $this->request = $request;
+ }
+
+ /**
+ * Check whether cookies are supported or not
+ *
+ * @return bool
+ */
+ public function isSupported()
+ {
+ if (! empty($_COOKIE)) {
+ $this->cleanupCheck();
+ return true;
+ }
+
+ $url = $this->request->getUrl();
+
+ if ($url->hasParam('_checkCookie') && empty($_COOKIE)) {
+ return false;
+ }
+
+ if (! $url->hasParam('_checkCookie')) {
+ $this->provideCheck();
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepare check to detect cookie support
+ */
+ public function provideCheck()
+ {
+ setcookie(self::CHECK_COOKIE, '1');
+
+ $requestUri = $this->request->getUrl()->addParams(array('_checkCookie' => 1));
+ $this->request->getResponse()->redirectAndExit($requestUri);
+ }
+
+ /**
+ * Cleanup the cookie support check
+ */
+ public function cleanupCheck()
+ {
+ if ($this->request->getUrl()->hasParam('_checkCookie') && isset($_COOKIE[self::CHECK_COOKIE])) {
+ $requestUri =$this->request->getUrl()->without('_checkCookie');
+ $this->request->getResponse()->redirectAndExit($requestUri);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Helper/HtmlPurifier.php b/library/Icinga/Web/Helper/HtmlPurifier.php
new file mode 100644
index 0000000..19fd207
--- /dev/null
+++ b/library/Icinga/Web/Helper/HtmlPurifier.php
@@ -0,0 +1,95 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Helper;
+
+use Closure;
+use Icinga\Web\FileCache;
+use InvalidArgumentException;
+
+class HtmlPurifier
+{
+ /**
+ * The actual purifier instance
+ *
+ * @var \HTMLPurifier
+ */
+ protected $purifier;
+
+ /**
+ * Create a new HtmlPurifier
+ *
+ * @param array|Closure $config Additional configuration
+ */
+ public function __construct($config = null)
+ {
+ $purifierConfig = \HTMLPurifier_Config::createDefault();
+ $purifierConfig->set('Core.EscapeNonASCIICharacters', true);
+ $purifierConfig->set('Attr.AllowedFrameTargets', array('_blank'));
+
+ if (($cachePath = FileCache::instance()->directory('htmlpurifier.cache')) !== false) {
+ $purifierConfig->set('Cache.SerializerPath', $cachePath);
+ } else {
+ $purifierConfig->set('Cache.DefinitionImpl', null);
+ }
+
+ // This avoids permission problems:
+ // $purifierConfig->set('Core.DefinitionCache', null);
+
+ // $purifierConfig->set('URI.Base', 'http://www.example.com');
+ // $purifierConfig->set('URI.MakeAbsolute', true);
+
+ $this->configure($purifierConfig);
+
+ if ($config instanceof Closure) {
+ call_user_func($config, $purifierConfig);
+ } elseif (is_array($config)) {
+ $purifierConfig->loadArray($config);
+ } elseif ($config !== null) {
+ throw new InvalidArgumentException('$config must be either a Closure or array');
+ }
+
+ $this->purifier = new \HTMLPurifier($purifierConfig);
+ }
+
+ /**
+ * Apply additional default configuration
+ *
+ * May be overwritten by more concrete purifier implementations.
+ *
+ * @param \HTMLPurifier_Config $config
+ */
+ protected function configure($config)
+ {
+ }
+
+ /**
+ * Purify and return the given HTML string
+ *
+ * @param string $html
+ * @param array|Closure $config Configuration to use instead of the default
+ *
+ * @return string
+ */
+ public function purify($html, $config = null)
+ {
+ return $this->purifier->purify($html, $config);
+ }
+
+ /**
+ * Purify and return the given HTML string
+ *
+ * Convenience method to bypass object creation.
+ *
+ * @param string $html
+ * @param array|Closure $config Additional configuration
+ *
+ * @return string
+ */
+ public static function process($html, $config = null)
+ {
+ $purifier = new static($config);
+
+ return $purifier->purify($html);
+ }
+}
diff --git a/library/Icinga/Web/Helper/Markdown.php b/library/Icinga/Web/Helper/Markdown.php
new file mode 100644
index 0000000..cb854b4
--- /dev/null
+++ b/library/Icinga/Web/Helper/Markdown.php
@@ -0,0 +1,34 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Helper;
+
+use Icinga\Web\Helper\Markdown\LinkTransformer;
+use Parsedown;
+
+class Markdown
+{
+ public static function line($content, $config = null)
+ {
+ if ($config === null) {
+ $config = function (\HTMLPurifier_Config $config) {
+ $config->set('HTML.Parent', 'span'); // Only allow inline elements
+
+ LinkTransformer::attachTo($config);
+ };
+ }
+
+ return HtmlPurifier::process(Parsedown::instance()->line($content), $config);
+ }
+
+ public static function text($content, $config = null)
+ {
+ if ($config === null) {
+ $config = function (\HTMLPurifier_Config $config) {
+ LinkTransformer::attachTo($config);
+ };
+ }
+
+ return HtmlPurifier::process(Parsedown::instance()->text($content), $config);
+ }
+}
diff --git a/library/Icinga/Web/Helper/Markdown/LinkTransformer.php b/library/Icinga/Web/Helper/Markdown/LinkTransformer.php
new file mode 100644
index 0000000..f323085
--- /dev/null
+++ b/library/Icinga/Web/Helper/Markdown/LinkTransformer.php
@@ -0,0 +1,73 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Helper\Markdown;
+
+use HTMLPurifier_AttrTransform;
+use HTMLPurifier_Config;
+use ipl\Web\Url;
+
+class LinkTransformer extends HTMLPurifier_AttrTransform
+{
+ /**
+ * Link targets that are considered to have a thumbnail
+ *
+ * @var string[]
+ */
+ public static $IMAGE_FILES = [
+ 'jpg',
+ 'jpeg',
+ 'png',
+ 'bmp',
+ 'gif',
+ 'heif',
+ 'heic',
+ 'webp'
+ ];
+
+ public function transform($attr, $config, $context)
+ {
+ if (! isset($attr['href'])) {
+ return $attr;
+ }
+
+ $url = Url::fromPath($attr['href']);
+ $fileName = basename($url->getPath());
+
+ $ext = null;
+ if (($extAt = strrpos($fileName, '.')) !== false) {
+ $ext = substr($fileName, $extAt + 1);
+ }
+
+ $hasThumbnail = $ext !== null && in_array($ext, static::$IMAGE_FILES, true);
+ if ($hasThumbnail) {
+ // I would have liked to not only base this off of the extension, but also by
+ // whether there is an actual img tag inside the anchor. Seems not possible :(
+ $attr['class'] = 'with-thumbnail';
+ }
+
+ if (! isset($attr['target'])) {
+ if ($url->isExternal()) {
+ $attr['target'] = '_blank';
+ } else {
+ $attr['data-base-target'] = '_next';
+ }
+ }
+
+ return $attr;
+ }
+
+ public static function attachTo(HTMLPurifier_Config $config)
+ {
+ $module = $config->getHTMLDefinition(true)
+ ->getAnonymousModule();
+
+ if (isset($module->info['a'])) {
+ $a = $module->info['a'];
+ } else {
+ $a = $module->addBlankElement('a');
+ }
+
+ $a->attr_transform_post[] = new self();
+ }
+}
diff --git a/library/Icinga/Web/Hook.php b/library/Icinga/Web/Hook.php
new file mode 100644
index 0000000..b098518
--- /dev/null
+++ b/library/Icinga/Web/Hook.php
@@ -0,0 +1,16 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Hook as NewHookImplementation;
+
+/**
+ * Icinga Web Hook registry
+ *
+ * @deprecated It is highly recommended to use {@see Icinga\Application\Hook} instead. Though since this message
+ * (or rather the previous message) hasn't been visible for ages... This won't be removed anyway....
+ */
+class Hook extends NewHookImplementation
+{
+}
diff --git a/library/Icinga/Web/JavaScript.php b/library/Icinga/Web/JavaScript.php
new file mode 100644
index 0000000..1865136
--- /dev/null
+++ b/library/Icinga/Web/JavaScript.php
@@ -0,0 +1,269 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Util\Json;
+use JShrink\Minifier;
+
+class JavaScript
+{
+ /** @var string */
+ const DEFINE_RE =
+ '/(?<!\.)define\(\s*([\'"][^\'"]*[\'"])?[,\s]*(\[[^]]*\])?[,\s]*((?>function\s*\([^)]*\)|[^=]*=>|\w+).*)/';
+
+ protected static $jsFiles = [
+ 'js/helpers.js',
+ 'js/icinga.js',
+ 'js/icinga/logger.js',
+ 'js/icinga/storage.js',
+ 'js/icinga/utils.js',
+ 'js/icinga/ui.js',
+ 'js/icinga/timer.js',
+ 'js/icinga/loader.js',
+ 'js/icinga/eventlistener.js',
+ 'js/icinga/events.js',
+ 'js/icinga/history.js',
+ 'js/icinga/module.js',
+ 'js/icinga/timezone.js',
+ 'js/icinga/behavior/application-state.js',
+ 'js/icinga/behavior/autofocus.js',
+ 'js/icinga/behavior/collapsible.js',
+ 'js/icinga/behavior/detach.js',
+ 'js/icinga/behavior/dropdown.js',
+ 'js/icinga/behavior/navigation.js',
+ 'js/icinga/behavior/form.js',
+ 'js/icinga/behavior/actiontable.js',
+ 'js/icinga/behavior/flyover.js',
+ 'js/icinga/behavior/filtereditor.js',
+ 'js/icinga/behavior/selectable.js',
+ 'js/icinga/behavior/modal.js',
+ 'js/icinga/behavior/input-enrichment.js',
+ 'js/icinga/behavior/datetime-picker.js',
+ 'js/icinga/behavior/copy-to-clipboard.js'
+ ];
+
+ protected static $vendorFiles = [];
+
+ protected static $baseFiles = [
+ 'js/define.js'
+ ];
+
+ public static function sendMinified()
+ {
+ self::send(true);
+ }
+
+ /**
+ * Send the client side script code to the client
+ *
+ * Does not cache the client side script code if the HTTP header Cache-Control or Pragma is set to no-cache.
+ *
+ * @param bool $minified Whether to compress the client side script code
+ */
+ public static function send($minified = false)
+ {
+ header('Content-Type: application/javascript');
+ $basedir = Icinga::app()->getBootstrapDirectory();
+ $moduleManager = Icinga::app()->getModuleManager();
+
+ $files = [];
+ $js = $out = '';
+ $min = $minified ? '.min' : '';
+
+ // Prepare vendor file list
+ $vendorFiles = [];
+ foreach (self::$vendorFiles as $file) {
+ $filePath = $basedir . '/' . $file . $min . '.js';
+ $vendorFiles[] = $filePath;
+ $files[] = $filePath;
+ }
+
+ // Prepare base file list
+ $baseFiles = [];
+ foreach (self::$baseFiles as $file) {
+ $filePath = $basedir . '/' . $file;
+ $baseFiles[] = $filePath;
+ $files[] = $filePath;
+ }
+
+ // Prepare library file list
+ foreach (Icinga::app()->getLibraries() as $library) {
+ $files = array_merge($files, $library->getJsAssets());
+ }
+
+ // Prepare core file list
+ $coreFiles = [];
+ foreach (self::$jsFiles as $file) {
+ $filePath = $basedir . '/' . $file;
+ $coreFiles[] = $filePath;
+ $files[] = $filePath;
+ }
+
+ $moduleFiles = [];
+ foreach ($moduleManager->getLoadedModules() as $name => $module) {
+ if ($module->hasJs()) {
+ $jsDir = $module->getJsDir();
+ foreach ($module->getJsFiles() as $path) {
+ if (file_exists($path)) {
+ $moduleFiles[$name][$jsDir][] = $path;
+ $files[] = $path;
+ }
+ }
+ }
+ }
+
+ $request = Icinga::app()->getRequest();
+ $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache';
+
+ header('Cache-Control: public,no-cache,must-revalidate');
+
+ if (! $noCache && FileCache::etagMatchesFiles($files)) {
+ header("HTTP/1.1 304 Not Modified");
+ return;
+ } else {
+ $etag = FileCache::etagForFiles($files);
+ }
+
+ header('ETag: "' . $etag . '"');
+ header('Content-Type: application/javascript');
+
+ $cacheFile = 'icinga-' . $etag . $min . '.js';
+ $cache = FileCache::instance();
+ if (! $noCache && $cache->has($cacheFile)) {
+ $cache->send($cacheFile);
+ return;
+ }
+
+ // We do not minify vendor files
+ foreach ($vendorFiles as $file) {
+ $out .= ';' . ltrim(trim(file_get_contents($file)), ';') . "\n";
+ }
+
+ $baseJs = '';
+ foreach ($baseFiles as $file) {
+ $baseJs .= file_get_contents($file) . "\n\n\n";
+ }
+
+ // Library files need to be namespaced first before they can be included
+ foreach (Icinga::app()->getLibraries() as $library) {
+ foreach ($library->getJsAssets() as $file) {
+ $alreadyMinified = false;
+ if ($minified && file_exists(($minFile = substr($file, 0, -3) . '.min.js'))) {
+ $alreadyMinified = true;
+ $file = $minFile;
+ }
+
+ $content = self::optimizeDefine(
+ file_get_contents($file),
+ $file,
+ $library->getJsAssetPath(),
+ $library->getName()
+ );
+
+ if ($alreadyMinified) {
+ $out .= ';' . ltrim(trim($content), ';') . "\n";
+ } else {
+ $js .= $content . "\n\n\n";
+ }
+ }
+ }
+
+ foreach ($coreFiles as $file) {
+ $js .= file_get_contents($file) . "\n\n\n";
+ }
+
+ foreach ($moduleFiles as $name => $paths) {
+ foreach ($paths as $basePath => $filePaths) {
+ foreach ($filePaths as $file) {
+ $content = self::optimizeDefine(file_get_contents($file), $file, $basePath, $name);
+ if (substr($file, -7, 7) === '.min.js') {
+ $out .= ';' . ltrim(trim($content), ';') . "\n";
+ } else {
+ $js .= $content . "\n\n\n";
+ }
+ }
+ }
+ }
+
+ if ($minified) {
+ $out .= Minifier::minify($js, ['flaggedComments' => false]);
+ $baseOut = Minifier::minify($baseJs, ['flaggedComments' => false]);
+ $out = ';' . ltrim($baseOut, ';') . "\n" . $out;
+ } else {
+ $out = $baseJs . $out . $js;
+ }
+
+ $cache->store($cacheFile, $out);
+ echo $out;
+ }
+
+ /**
+ * Optimize define() calls in the given JS
+ *
+ * @param string $js
+ * @param string $filePath
+ * @param string $basePath
+ * @param string $packageName
+ *
+ * @return string
+ */
+ public static function optimizeDefine($js, $filePath, $basePath, $packageName)
+ {
+ if (! preg_match(self::DEFINE_RE, $js, $match) || strpos($js, 'define.amd') !== false) {
+ return $js;
+ }
+
+ try {
+ $assetName = $match[1] ? Json::decode($match[1]) : '';
+ if (! $assetName) {
+ $assetName = explode('.', basename($filePath))[0];
+ }
+
+ $assetName = join(DIRECTORY_SEPARATOR, array_filter([
+ $packageName,
+ ltrim(substr(dirname($filePath), strlen($basePath)), DIRECTORY_SEPARATOR),
+ $assetName
+ ]));
+
+ $assetName = Json::encode($assetName, JSON_UNESCAPED_SLASHES);
+ } catch (JsonDecodeException $_) {
+ $assetName = $match[1];
+ Logger::debug('Can\'t optimize name of "%s". Are single quotes used instead of double quotes?', $filePath);
+ }
+
+ try {
+ $dependencies = $match[2] ? Json::decode($match[2]) : [];
+ foreach ($dependencies as &$dependencyName) {
+ if ($dependencyName === 'exports') {
+ // exports is a special keyword and doesn't need optimization
+ continue;
+ }
+
+ if (preg_match('~^((?:\.\.?/)+)*(.*)~', $dependencyName, $natch)) {
+ $dependencyName = join(DIRECTORY_SEPARATOR, array_filter([
+ $packageName,
+ ltrim(substr(
+ realpath(join(DIRECTORY_SEPARATOR, [dirname($filePath), $natch[1]])),
+ strlen(realpath($basePath))
+ ), DIRECTORY_SEPARATOR),
+ $natch[2]
+ ]));
+ }
+ }
+
+ $dependencies = Json::encode($dependencies, JSON_UNESCAPED_SLASHES);
+ } catch (JsonDecodeException $_) {
+ $dependencies = $match[2];
+ Logger::debug(
+ 'Can\'t optimize dependencies of "%s". Are single quotes used instead of double quotes?',
+ $filePath
+ );
+ }
+
+ return str_replace($match[0], sprintf("define(%s, %s, %s", $assetName, $dependencies, $match[3]), $js);
+ }
+}
diff --git a/library/Icinga/Web/LessCompiler.php b/library/Icinga/Web/LessCompiler.php
new file mode 100644
index 0000000..d7eda09
--- /dev/null
+++ b/library/Icinga/Web/LessCompiler.php
@@ -0,0 +1,255 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Logger;
+use Icinga\Util\LessParser;
+use Less_Exception_Parser;
+
+/**
+ * Compile LESS into CSS
+ *
+ * Comments will be removed always. lessc is messing them up.
+ */
+class LessCompiler
+{
+ /**
+ * lessphp compiler
+ *
+ * @var LessParser
+ */
+ protected $lessc;
+
+ /**
+ * Array of LESS files
+ *
+ * @var string[]
+ */
+ protected $lessFiles = array();
+
+ /**
+ * Array of module LESS files indexed by module names
+ *
+ * @var array[]
+ */
+ protected $moduleLessFiles = array();
+
+ /**
+ * LESS source
+ *
+ * @var string
+ */
+ protected $source;
+
+ /**
+ * Path of the LESS theme
+ *
+ * @var string
+ */
+ protected $theme;
+
+ /**
+ * Path of the LESS theme mode
+ *
+ * @var string
+ */
+ protected $themeMode;
+
+ /**
+ * Create a new LESS compiler
+ */
+ public function __construct()
+ {
+ $this->lessc = new LessParser();
+ }
+
+ /**
+ * Add a Web 2 LESS file
+ *
+ * @param string $lessFile Path to the LESS file
+ *
+ * @return $this
+ */
+ public function addLessFile($lessFile)
+ {
+ $this->lessFiles[] = realpath($lessFile);
+ return $this;
+ }
+
+ /**
+ * Add a module LESS file
+ *
+ * @param string $moduleName Name of the module
+ * @param string $lessFile Path to the LESS file
+ *
+ * @return $this
+ */
+ public function addModuleLessFile($moduleName, $lessFile)
+ {
+ if (! isset($this->moduleLessFiles[$moduleName])) {
+ $this->moduleLessFiles[$moduleName] = array();
+ }
+ $this->moduleLessFiles[$moduleName][] = realpath($lessFile);
+ return $this;
+ }
+
+ /**
+ * Get the list of LESS files added to the compiler
+ *
+ * @return string[]
+ */
+ public function getLessFiles()
+ {
+ $lessFiles = $this->lessFiles;
+
+ foreach ($this->moduleLessFiles as $moduleLessFiles) {
+ $lessFiles = array_merge($lessFiles, $moduleLessFiles);
+ }
+
+ if ($this->theme !== null) {
+ $lessFiles[] = $this->theme;
+ }
+
+ if ($this->themeMode !== null) {
+ $lessFiles[] = $this->themeMode;
+ }
+
+ return $lessFiles;
+ }
+
+ /**
+ * Set the path to the LESS theme
+ *
+ * @param ?string $theme Path to the LESS theme
+ *
+ * @return $this
+ */
+ public function setTheme($theme)
+ {
+ if ($theme === null || (is_file($theme) && is_readable($theme))) {
+ $this->theme = $theme;
+ } else {
+ Logger::error('Can\t load theme %s. Make sure that the theme exists and is readable', $theme);
+ }
+ return $this;
+ }
+
+ /**
+ * Set the path to the LESS theme mode
+ *
+ * @param string $themeMode Path to the LESS theme mode
+ *
+ * @return $this
+ */
+ public function setThemeMode($themeMode)
+ {
+ if (is_file($themeMode) && is_readable($themeMode)) {
+ $this->themeMode = $themeMode;
+ } else {
+ Logger::error('Can\t load theme mode %s. Make sure that the theme mode exists and is readable', $themeMode);
+ }
+ return $this;
+ }
+
+ /**
+ * Instruct the compiler to minify CSS
+ *
+ * @return $this
+ */
+ public function compress()
+ {
+ $this->lessc->setFormatter('compressed');
+ return $this;
+ }
+
+ /**
+ * Render to CSS
+ *
+ * @return string
+ */
+ public function render()
+ {
+ foreach ($this->lessFiles as $lessFile) {
+ $this->source .= file_get_contents($lessFile);
+ }
+
+ $moduleCss = '';
+ $exportedVars = [];
+ foreach ($this->moduleLessFiles as $moduleName => $moduleLessFiles) {
+ $moduleCss .= '.icinga-module.module-' . $moduleName . ' {';
+
+ foreach ($moduleLessFiles as $moduleLessFile) {
+ $content = file_get_contents($moduleLessFile);
+
+ $pattern = '/^@exports:\s*{((?:\s*@[^:}]+:[^;]*;\s+)+)};$/m';
+ if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
+ foreach ($matches as $match) {
+ $content = str_replace($match[0], '', $content);
+ foreach (explode("\n", trim($match[1])) as $line) {
+ list($name, $value) = explode(':', $line, 2);
+ $exportedVars[trim($name)] = trim($value, ' ;');
+ }
+ }
+ }
+
+ $moduleCss .= $content;
+ }
+
+ $moduleCss .= '}';
+ }
+
+ $this->source .= $moduleCss;
+
+ $varExports = '';
+ foreach ($exportedVars as $name => $value) {
+ $varExports .= sprintf("%s: %s;\n", $name, $value);
+ }
+
+ // exported vars are injected at the beginning to avoid that they are
+ // able to override other variables, that's what themes are for
+ $this->source = $varExports . "\n\n" . $this->source;
+
+ if ($this->theme !== null) {
+ $this->source .= file_get_contents($this->theme);
+ }
+
+ if ($this->themeMode !== null) {
+ $this->source .= file_get_contents($this->themeMode);
+ }
+
+ try {
+ return preg_replace(
+ '/(\.icinga-module\.module-[^\s]+) (#layout\.[^\s]+)/m',
+ '\2 \1',
+ $this->lessc->compile($this->source)
+ );
+ } catch (Less_Exception_Parser $e) {
+ $excerpt = substr($this->source, $e->index - 500, 1000);
+
+ $lines = [];
+ $found = false;
+ $pos = $e->index - 500;
+ foreach (explode("\n", $excerpt) as $i => $line) {
+ if ($i === 0) {
+ $pos += strlen($line);
+ $lines[] = '.. ' . $line;
+ } else {
+ $pos += strlen($line) + 1;
+ $sep = ' ';
+ if (! $found && $pos > $e->index) {
+ $found = true;
+ $sep = '!! ';
+ }
+
+ $lines[] = $sep . $line;
+ }
+ }
+
+ $lines[] = '..';
+ $excerpt = join("\n", $lines);
+
+ return sprintf("%s\n%s\n\n\n%s", $e->getMessage(), $e->getTraceAsString(), $excerpt);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Menu.php b/library/Icinga/Web/Menu.php
new file mode 100644
index 0000000..dc1cdc8
--- /dev/null
+++ b/library/Icinga/Web/Menu.php
@@ -0,0 +1,152 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Logger;
+use Icinga\Authentication\Auth;
+use Icinga\Web\Navigation\Navigation;
+
+/**
+ * Main menu for Icinga Web 2
+ */
+class Menu extends Navigation
+{
+ /**
+ * Create the main menu
+ */
+ public function __construct()
+ {
+ $this->init();
+ $this->load('menu-item');
+ }
+
+ /**
+ * Setup the main menu
+ */
+ public function init()
+ {
+ $this->addItem('dashboard', [
+ 'label' => t('Dashboard'),
+ 'url' => 'dashboard',
+ 'icon' => 'dashboard',
+ 'priority' => 10
+ ]);
+ $this->addItem('system', [
+ 'cssClass' => 'system-nav-item',
+ 'label' => t('System'),
+ 'icon' => 'services',
+ 'priority' => 700,
+ 'renderer' => [
+ 'SummaryNavigationItemRenderer',
+ 'state' => 'critical'
+ ],
+ 'children' => [
+ 'about' => [
+ 'icon' => 'info',
+ 'description' => t('Open about page'),
+ 'label' => t('About'),
+ 'url' => 'about',
+ 'priority' => 700
+ ],
+ 'health' => [
+ 'icon' => 'eye',
+ 'description' => t('Open health overview'),
+ 'label' => t('Health'),
+ 'url' => 'health',
+ 'priority' => 710,
+ 'renderer' => 'HealthNavigationRenderer'
+ ],
+ 'announcements' => [
+ 'icon' => 'megaphone',
+ 'description' => t('List announcements'),
+ 'label' => t('Announcements'),
+ 'url' => 'announcements',
+ 'priority' => 720
+ ],
+ 'sessions' => [
+ 'icon' => 'host',
+ 'description' => t('List of users who stay logged in'),
+ 'label' => t('User Sessions'),
+ 'permission' => 'application/sessions',
+ 'url' => 'manage-user-devices',
+ 'priority' => 730
+ ]
+ ]
+ ]);
+ $this->addItem('configuration', [
+ 'cssClass' => 'configuration-nav-item',
+ 'label' => t('Configuration'),
+ 'icon' => 'wrench',
+ 'permission' => 'config/*',
+ 'priority' => 800,
+ 'children' => [
+ 'application' => [
+ 'icon' => 'wrench',
+ 'description' => t('Open application configuration'),
+ 'label' => t('Application'),
+ 'url' => 'config',
+ 'priority' => 810
+ ],
+ 'authentication' => [
+ 'icon' => 'users',
+ 'description' => t('Open access control configuration'),
+ 'label' => t('Access Control'),
+ 'permission' => 'config/access-control/*',
+ 'priority' => 830,
+ 'url' => 'role'
+ ],
+ 'navigation' => [
+ 'icon' => 'sitemap',
+ 'description' => t('Open shared navigation configuration'),
+ 'label' => t('Shared Navigation'),
+ 'url' => 'navigation/shared',
+ 'permission' => 'config/navigation',
+ 'priority' => 840,
+ ],
+ 'modules' => [
+ 'icon' => 'cubes',
+ 'description' => t('Open module configuration'),
+ 'label' => t('Modules'),
+ 'url' => 'config/modules',
+ 'permission' => 'config/modules',
+ 'priority' => 890
+ ]
+ ]
+ ]);
+ $this->addItem('user', [
+ 'cssClass' => 'user-nav-item',
+ 'label' => Auth::getInstance()->getUser()->getUsername(),
+ 'icon' => 'user',
+ 'priority' => 900,
+ 'children' => [
+ 'account' => [
+ 'icon' => 'sliders',
+ 'description' => t('Open your account preferences'),
+ 'label' => t('My Account'),
+ 'priority' => 100,
+ 'url' => 'account'
+ ],
+ 'logout' => [
+ 'icon' => 'off',
+ 'description' => t('Log out'),
+ 'label' => t('Logout'),
+ 'priority' => 200,
+ 'attributes' => ['target' => '_self'],
+ 'url' => 'authentication/logout'
+ ]
+ ]
+ ]);
+
+ if (Logger::writesToFile()) {
+ $this->getItem('system')->addChild($this->createItem('application_log', [
+ 'icon' => 'doc-text',
+ 'description' => t('Open Application Log'),
+ 'label' => t('Application Log'),
+ 'url' => 'list/applicationlog',
+ 'permission' => 'application/log',
+ 'priority' => 900
+ ]));
+ }
+ }
+}
diff --git a/library/Icinga/Web/Navigation/ConfigMenu.php b/library/Icinga/Web/Navigation/ConfigMenu.php
new file mode 100644
index 0000000..583bf42
--- /dev/null
+++ b/library/Icinga/Web/Navigation/ConfigMenu.php
@@ -0,0 +1,327 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use Icinga\Application\Hook\HealthHook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\MigrationManager;
+use Icinga\Authentication\Auth;
+use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBadge;
+use Throwable;
+
+class ConfigMenu extends BaseHtmlElement
+{
+ const STATE_OK = 'ok';
+ const STATE_CRITICAL = 'critical';
+ const STATE_WARNING = 'warning';
+ const STATE_PENDING = 'pending';
+ const STATE_UNKNOWN = 'unknown';
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'nav'];
+
+ protected $children;
+
+ protected $selected;
+
+ protected $state;
+
+ public function __construct()
+ {
+ $this->children = [
+ 'system' => [
+ 'title' => t('System'),
+ 'items' => [
+ 'about' => [
+ 'label' => t('About'),
+ 'url' => 'about'
+ ],
+ 'health' => [
+ 'label' => t('Health'),
+ 'url' => 'health',
+ ],
+ 'migrations' => [
+ 'label' => t('Migrations'),
+ 'url' => 'migrations',
+ ],
+ 'announcements' => [
+ 'label' => t('Announcements'),
+ 'url' => 'announcements'
+ ],
+ 'sessions' => [
+ 'label' => t('User Sessions'),
+ 'permission' => 'application/sessions',
+ 'url' => 'manage-user-devices'
+ ]
+ ]
+ ],
+ 'configuration' => [
+ 'title' => t('Configuration'),
+ 'permission' => 'config/*',
+ 'items' => [
+ 'application' => [
+ 'label' => t('Application'),
+ 'url' => 'config/general'
+ ],
+ 'authentication' => [
+ 'label' => t('Access Control'),
+ 'permission' => 'config/access-control/*',
+ 'url' => 'role/list'
+ ],
+ 'navigation' => [
+ 'label' => t('Shared Navigation'),
+ 'permission' => 'config/navigation',
+ 'url' => 'navigation/shared'
+ ],
+ 'modules' => [
+ 'label' => t('Modules'),
+ 'permission' => 'config/modules',
+ 'url' => 'config/modules'
+ ]
+ ]
+ ],
+ 'logout' => [
+ 'items' => [
+ 'logout' => [
+ 'label' => t('Logout'),
+ 'atts' => [
+ 'target' => '_self',
+ 'class' => 'nav-item-logout'
+ ],
+ 'url' => 'authentication/logout'
+ ]
+ ]
+ ]
+ ];
+
+ if (Logger::writesToFile()) {
+ $this->children['system']['items']['application_log'] = [
+ 'label' => t('Application Log'),
+ 'url' => 'list/applicationlog',
+ 'permission' => 'application/log'
+ ];
+ }
+ }
+
+ protected function assembleUserMenuItem(BaseHtmlElement $userMenuItem)
+ {
+ $username = Auth::getInstance()->getUser()->getUsername();
+
+ $userMenuItem->add(
+ new HtmlElement(
+ 'a',
+ Attributes::create(['href' => Url::fromPath('account')]),
+ new HtmlElement(
+ 'i',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($username[0])
+ ),
+ Text::create($username)
+ )
+ );
+
+ if (Icinga::app()->getRequest()->getUrl()->matches('account')) {
+ $userMenuItem->addAttributes(['class' => 'selected active']);
+ }
+ }
+
+ protected function assembleCogMenuItem($cogMenuItem)
+ {
+ $cogMenuItem->add([
+ HtmlElement::create(
+ 'button',
+ null,
+ [
+ new Icon('cog'),
+ $this->createHealthBadge() ?? $this->createMigrationBadge(),
+ ]
+ ),
+ $this->createLevel2Menu()
+ ]);
+ }
+
+ protected function assembleLevel2Nav(BaseHtmlElement $level2Nav)
+ {
+ $navContent = HtmlElement::create('div', ['class' => 'flyout-content']);
+ foreach ($this->children as $c) {
+ if (isset($c['permission']) && ! Auth::getInstance()->hasPermission($c['permission'])) {
+ continue;
+ }
+
+ if (isset($c['title'])) {
+ $navContent->add(HtmlElement::create(
+ 'h3',
+ null,
+ $c['title']
+ ));
+ }
+
+ $ul = HtmlElement::create('ul', ['class' => 'nav']);
+ foreach ($c['items'] as $key => $item) {
+ $ul->add($this->createLevel2MenuItem($item, $key));
+ }
+
+ $navContent->add($ul);
+ }
+
+ $level2Nav->add($navContent);
+ }
+
+ protected function getHealthCount()
+ {
+ $count = 0;
+ $worstState = null;
+ foreach (HealthHook::collectHealthData()->select() as $result) {
+ if ($worstState === null || $result->state > $worstState) {
+ $worstState = $result->state;
+ $count = 1;
+ } elseif ($worstState === $result->state) {
+ $count++;
+ }
+ }
+
+ switch ($worstState) {
+ case HealthHook::STATE_OK:
+ $count = 0;
+ break;
+ case HealthHook::STATE_WARNING:
+ $this->state = self::STATE_WARNING;
+ break;
+ case HealthHook::STATE_CRITICAL:
+ $this->state = self::STATE_CRITICAL;
+ break;
+ case HealthHook::STATE_UNKNOWN:
+ $this->state = self::STATE_UNKNOWN;
+ break;
+ }
+
+ return $count;
+ }
+
+ protected function isSelectedItem($item)
+ {
+ if ($item !== null && Icinga::app()->getRequest()->getUrl()->matches($item['url'])) {
+ $this->selected = $item;
+ return true;
+ }
+
+ return false;
+ }
+
+ protected function createHealthBadge(): ?StateBadge
+ {
+ $stateBadge = null;
+ if ($this->getHealthCount() > 0) {
+ $stateBadge = new StateBadge($this->getHealthCount(), $this->state);
+ $stateBadge->addAttributes(['class' => 'disabled']);
+ }
+
+ return $stateBadge;
+ }
+
+ protected function createMigrationBadge(): ?StateBadge
+ {
+ try {
+ $mm = MigrationManager::instance();
+ $count = $mm->count();
+ } catch (Throwable $e) {
+ Logger::error('Failed to load pending migrations: %s', $e);
+ $count = 0;
+ }
+
+ $stateBadge = null;
+ if ($count > 0) {
+ $stateBadge = new StateBadge($count, BadgeNavigationItemRenderer::STATE_PENDING);
+ $stateBadge->addAttributes(['class' => 'disabled']);
+ }
+
+ return $stateBadge;
+ }
+
+ protected function createLevel2Menu()
+ {
+ $level2Nav = HtmlElement::create(
+ 'div',
+ Attributes::create(['class' => 'nav-level-1 flyout'])
+ );
+
+ $this->assembleLevel2Nav($level2Nav);
+
+ return $level2Nav;
+ }
+
+ protected function createLevel2MenuItem($item, $key)
+ {
+ if (isset($item['permission']) && ! Auth::getInstance()->hasPermission($item['permission'])) {
+ return null;
+ }
+
+ $stateBadge = null;
+ $class = null;
+ if ($key === 'health') {
+ $class = 'badge-nav-item';
+ $stateBadge = $this->createHealthBadge();
+ } elseif ($key === 'migrations') {
+ $class = 'badge-nav-item';
+ $stateBadge = $this->createMigrationBadge();
+ }
+
+ $li = HtmlElement::create(
+ 'li',
+ $item['atts'] ?? [],
+ [
+ HtmlElement::create(
+ 'a',
+ Attributes::create(['href' => Url::fromPath($item['url'])]),
+ [
+ $item['label'],
+ $stateBadge ?? ''
+ ]
+ ),
+ ]
+ );
+ $li->addAttributes(['class' => $class]);
+
+ if ($this->isSelectedItem($item)) {
+ $li->addAttributes(['class' => 'selected']);
+ }
+
+ return $li;
+ }
+
+ protected function createUserMenuItem()
+ {
+ $userMenuItem = HtmlElement::create('li', ['class' => 'user-nav-item']);
+
+ $this->assembleUserMenuItem($userMenuItem);
+
+ return $userMenuItem;
+ }
+
+ protected function createCogMenuItem()
+ {
+ $cogMenuItem = HtmlElement::create('li', ['class' => 'config-nav-item']);
+
+ $this->assembleCogMenuItem($cogMenuItem);
+
+ return $cogMenuItem;
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createUserMenuItem(),
+ $this->createCogMenuItem()
+ ]);
+ }
+}
diff --git a/library/Icinga/Web/Navigation/DashboardPane.php b/library/Icinga/Web/Navigation/DashboardPane.php
new file mode 100644
index 0000000..71b3215
--- /dev/null
+++ b/library/Icinga/Web/Navigation/DashboardPane.php
@@ -0,0 +1,84 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use Icinga\Web\Url;
+
+/**
+ * A dashboard pane
+ */
+class DashboardPane extends NavigationItem
+{
+ /**
+ * This pane's dashlets
+ *
+ * @var array
+ */
+ protected $dashlets;
+
+ protected $disabled;
+
+ /**
+ * Set this pane's dashlets
+ *
+ * @param array $dashlets
+ *
+ * @return $this
+ */
+ public function setDashlets(array $dashlets)
+ {
+ $this->dashlets = $dashlets;
+ return $this;
+ }
+
+ /**
+ * Return this pane's dashlets
+ *
+ * @param bool $ordered Whether to order the dashlets first
+ *
+ * @return array
+ */
+ public function getDashlets($ordered = true)
+ {
+ if ($this->dashlets === null) {
+ return array();
+ }
+
+ if ($ordered) {
+ $dashlets = $this->dashlets;
+ ksort($dashlets);
+ return $dashlets;
+ }
+
+ return $this->dashlets;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function init()
+ {
+ $this->setUrl(Url::fromPath('dashboard', array('pane' => $this->getName())));
+ }
+
+ /**
+ * Set disabled state for pane
+ *
+ * @param bool $disabled
+ */
+ public function setDisabled($disabled = true)
+ {
+ $this->disabled = (bool) $disabled;
+ }
+
+ /**
+ * Get disabled state for pane
+ *
+ * @return bool
+ */
+ public function getDisabled()
+ {
+ return $this->disabled;
+ }
+}
diff --git a/library/Icinga/Web/Navigation/DropdownItem.php b/library/Icinga/Web/Navigation/DropdownItem.php
new file mode 100644
index 0000000..2342b96
--- /dev/null
+++ b/library/Icinga/Web/Navigation/DropdownItem.php
@@ -0,0 +1,20 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+/**
+ * Dropdown navigation item
+ *
+ * @see \Icinga\Web\Navigation\Navigation For a usage example.
+ */
+class DropdownItem extends NavigationItem
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function init()
+ {
+ $this->children->setLayout(Navigation::LAYOUT_DROPDOWN);
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Navigation.php b/library/Icinga/Web/Navigation/Navigation.php
new file mode 100644
index 0000000..4343c3c
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Navigation.php
@@ -0,0 +1,572 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use ArrayAccess;
+use ArrayIterator;
+use Exception;
+use Countable;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Traversable;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Authentication\Auth;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Util\StringHelper;
+use Icinga\Web\Navigation\Renderer\RecursiveNavigationRenderer;
+
+/**
+ * Container for navigation items
+ */
+class Navigation implements ArrayAccess, Countable, IteratorAggregate
+{
+ /**
+ * The class namespace where to locate navigation type classes
+ *
+ * @var string
+ */
+ const NAVIGATION_NS = 'Web\\Navigation';
+
+ /**
+ * Flag for dropdown layout
+ *
+ * @var int
+ */
+ const LAYOUT_DROPDOWN = 1;
+
+ /**
+ * Flag for tabs layout
+ *
+ * @var int
+ */
+ const LAYOUT_TABS = 2;
+
+ /**
+ * Known navigation types
+ *
+ * @var array
+ */
+ protected static $types;
+
+ /**
+ * This navigation's items
+ *
+ * @var NavigationItem[]
+ */
+ protected $items = array();
+
+ /**
+ * This navigation's layout
+ *
+ * @var int
+ */
+ protected $layout;
+
+ public function offsetExists($offset): bool
+ {
+ return isset($this->items[$offset]);
+ }
+
+ public function offsetGet($offset): ?NavigationItem
+ {
+ return $this->items[$offset] ?? null;
+ }
+
+ public function offsetSet($offset, $value): void
+ {
+ $this->items[$offset] = $value;
+ }
+
+ public function offsetUnset($offset): void
+ {
+ unset($this->items[$offset]);
+ }
+
+ public function count(): int
+ {
+ return count($this->items);
+ }
+
+ public function getIterator(): Traversable
+ {
+ $this->order();
+ return new ArrayIterator($this->items);
+ }
+
+ /**
+ * Create and return a new navigation item for the given configuration
+ *
+ * @param string $name
+ * @param array|ConfigObject $properties
+ *
+ * @return NavigationItem
+ *
+ * @throws InvalidArgumentException If the $properties argument is neither an array nor a ConfigObject
+ */
+ public function createItem($name, $properties)
+ {
+ if ($properties instanceof ConfigObject) {
+ $properties = $properties->toArray();
+ } elseif (! is_array($properties)) {
+ throw new InvalidArgumentException('Argument $properties must be of type array or ConfigObject');
+ }
+
+ $itemType = isset($properties['type']) ? StringHelper::cname($properties['type'], '-') : 'NavigationItem';
+ if (! empty(static::$types) && isset(static::$types[$itemType])) {
+ return new static::$types[$itemType]($name, $properties);
+ }
+
+ $item = null;
+ $classPath = null;
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ $classPath = 'Icinga\\Module\\'
+ . ucfirst($module->getName())
+ . '\\'
+ . static::NAVIGATION_NS
+ . '\\'
+ . $itemType;
+ if (class_exists($classPath)) {
+ $item = new $classPath($name, $properties);
+ break;
+ }
+ }
+
+ if ($item === null) {
+ $classPath = 'Icinga\\' . static::NAVIGATION_NS . '\\' . $itemType;
+ if (class_exists($classPath)) {
+ $item = new $classPath($name, $properties);
+ }
+ }
+
+ if ($item === null) {
+ if ($itemType !== 'MenuItem') {
+ Logger::debug(
+ 'Failed to find custom navigation item class %s for item %s. Using base class NavigationItem now',
+ $itemType,
+ $name
+ );
+ }
+
+ $item = new NavigationItem($name, $properties);
+ static::$types[$itemType] = 'Icinga\\Web\\Navigation\\NavigationItem';
+ } elseif (! $item instanceof NavigationItem) {
+ throw new ProgrammingError('Class %s must inherit from NavigationItem', $classPath);
+ } else {
+ static::$types[$itemType] = $classPath;
+ }
+
+ return $item;
+ }
+
+ /**
+ * Add a navigation item
+ *
+ * If you do not pass an instance of NavigationItem, this will only add the item
+ * if it does not require a permission or the current user has the permission.
+ *
+ * @param string|NavigationItem $name The name of the item or an instance of NavigationItem
+ * @param array $properties The properties of the item to add (Ignored if $name is not a string)
+ *
+ * @return bool Whether the item was added or not
+ *
+ * @throws InvalidArgumentException In case $name is neither a string nor an instance of NavigationItem
+ */
+ public function addItem($name, array $properties = array())
+ {
+ if (is_string($name)) {
+ if (isset($properties['permission'])) {
+ if (! Auth::getInstance()->hasPermission($properties['permission'])) {
+ return false;
+ }
+
+ unset($properties['permission']);
+ }
+
+ $item = $this->createItem($name, $properties);
+ } elseif (! $name instanceof NavigationItem) {
+ throw new InvalidArgumentException('Argument $name must be of type string or NavigationItem');
+ } else {
+ $item = $name;
+ }
+
+ $this->items[$item->getName()] = $item;
+ return true;
+ }
+
+ /**
+ * Return the item with the given name
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return NavigationItem|mixed
+ */
+ public function getItem($name, $default = null)
+ {
+ return isset($this->items[$name]) ? $this->items[$name] : $default;
+ }
+
+ /**
+ * Return the currently active item or the first one if none is active
+ *
+ * @return NavigationItem
+ */
+ public function getActiveItem()
+ {
+ foreach ($this->items as $item) {
+ if ($item->getActive()) {
+ return $item;
+ }
+ }
+
+ $firstItem = reset($this->items);
+ return $firstItem ? $firstItem->setActive() : null;
+ }
+
+ /**
+ * Return this navigation's items
+ *
+ * @return array
+ */
+ public function getItems()
+ {
+ return $this->items;
+ }
+
+ /**
+ * Return whether this navigation is empty
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return empty($this->items);
+ }
+
+ /**
+ * Return whether this navigation has any renderable items
+ *
+ * @return bool
+ */
+ public function hasRenderableItems()
+ {
+ foreach ($this->getItems() as $item) {
+ if ($item->shouldRender()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return this navigation's layout
+ *
+ * @return int
+ */
+ public function getLayout()
+ {
+ return $this->layout;
+ }
+
+ /**
+ * Set this navigation's layout
+ *
+ * @param int $layout
+ *
+ * @return $this
+ */
+ public function setLayout($layout)
+ {
+ $this->layout = (int) $layout;
+ return $this;
+ }
+
+ /**
+ * Create and return the renderer for this navigation
+ *
+ * @return RecursiveNavigationRenderer
+ */
+ public function getRenderer()
+ {
+ return new RecursiveNavigationRenderer($this);
+ }
+
+ /**
+ * Return this navigation rendered to HTML
+ *
+ * @return string
+ */
+ public function render()
+ {
+ return $this->getRenderer()->render();
+ }
+
+ /**
+ * Order this navigation's items
+ *
+ * @return $this
+ */
+ public function order()
+ {
+ uasort($this->items, array($this, 'compareItems'));
+ foreach ($this->items as $item) {
+ if ($item->hasChildren()) {
+ $item->getChildren()->order();
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether the first item is less than, more than or equal to the second one
+ *
+ * @param NavigationItem $a
+ * @param NavigationItem $b
+ *
+ * @return int
+ */
+ protected function compareItems(NavigationItem $a, NavigationItem $b)
+ {
+ if ($a->getPriority() === $b->getPriority()) {
+ return strcasecmp($a->getLabel(), $b->getLabel());
+ }
+
+ return $a->getPriority() > $b->getPriority() ? 1 : -1;
+ }
+
+ /**
+ * Try to find and return a item with the given or a similar name
+ *
+ * @param string $name
+ *
+ * @return ?NavigationItem
+ */
+ public function findItem($name)
+ {
+ $item = $this->getItem($name);
+ if ($item !== null) {
+ return $item;
+ }
+
+ $loweredName = strtolower($name);
+ foreach ($this->getItems() as $item) {
+ if (strtolower($item->getName()) === $loweredName) {
+ return $item;
+ }
+ }
+ }
+
+ /**
+ * Merge this navigation with the given one
+ *
+ * Any duplicate items of this navigation will be overwritten by the given navigation's items.
+ *
+ * @param Navigation $navigation
+ *
+ * @return $this
+ */
+ public function merge(Navigation $navigation)
+ {
+ foreach ($navigation as $item) {
+ /** @var $item NavigationItem */
+ if (($existingItem = $this->findItem($item->getName())) !== null) {
+ if ($existingItem->conflictsWith($item)) {
+ $name = $item->getName();
+ do {
+ if (preg_match('~_(\d+)$~', $name, $matches)) {
+ $name = preg_replace('~_\d+$~', (int) $matches[1] + 1, $name);
+ } else {
+ $name .= '_2';
+ }
+ } while ($this->getItem($name) !== null);
+
+ $this->addItem($item->setName($name));
+ } else {
+ $existingItem->merge($item);
+ }
+ } else {
+ $this->addItem($item);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Extend this navigation set with all additional items of the given type
+ *
+ * This will fetch navigation items from the following sources:
+ * * User Shareables
+ * * User Preferences
+ * * Modules
+ * Any existing entry will be overwritten by one that is coming later in order.
+ *
+ * @param string $type
+ *
+ * @return $this
+ */
+ public function load($type)
+ {
+ $user = Auth::getInstance()->getUser();
+ if ($type !== 'dashboard-pane') {
+ // Shareables
+ $this->merge(Icinga::app()->getSharedNavigation($type));
+
+ // User Preferences
+ $this->merge($user->getNavigation($type));
+ }
+
+ // Modules
+ $moduleManager = Icinga::app()->getModuleManager();
+ foreach ($moduleManager->getLoadedModules() as $module) {
+ if ($user->can($moduleManager::MODULE_PERMISSION_NS . $module->getName())) {
+ if ($type === 'menu-item') {
+ $this->merge($module->getMenu());
+ } elseif ($type === 'dashboard-pane') {
+ $this->merge($module->getDashboard());
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the global navigation item type configuration
+ *
+ * @return array
+ */
+ public static function getItemTypeConfiguration()
+ {
+ $defaultItemTypes = array(
+ 'menu-item' => array(
+ 'label' => t('Menu Entry'),
+ 'config' => 'menu'
+ )/*, // Disabled, until it is able to fully replace the old implementation
+ 'dashlet' => array(
+ 'label' => 'Dashlet',
+ 'config' => 'dashboard'
+ )*/
+ );
+
+ $moduleItemTypes = array();
+ $moduleManager = Icinga::app()->getModuleManager();
+ foreach ($moduleManager->getLoadedModules() as $module) {
+ if (Auth::getInstance()->hasPermission($moduleManager::MODULE_PERMISSION_NS . $module->getName())) {
+ foreach ($module->getNavigationItems() as $type => $options) {
+ if (! isset($moduleItemTypes[$type])) {
+ $moduleItemTypes[$type] = $options;
+ }
+ }
+ }
+ }
+
+ return array_merge($defaultItemTypes, $moduleItemTypes);
+ }
+
+ /**
+ * Create and return a new set of navigation items for the given configuration
+ *
+ * Note that this is supposed to be utilized for one dimensional structures
+ * only. Multi dimensional structures can be processed by fromArray().
+ *
+ * @param Traversable|array $config
+ *
+ * @return Navigation
+ *
+ * @throws InvalidArgumentException In case the given configuration is invalid
+ * @throws ConfigurationError In case a referenced parent does not exist
+ */
+ public static function fromConfig($config)
+ {
+ if (! is_array($config) && !$config instanceof Traversable) {
+ throw new InvalidArgumentException('Argument $config must be an array or a instance of Traversable');
+ }
+
+ $flattened = $orphans = $topLevel = array();
+ foreach ($config as $sectionName => $sectionConfig) {
+ $parentName = $sectionConfig->parent;
+ unset($sectionConfig->parent);
+
+ if (! $parentName) {
+ $topLevel[$sectionName] = $sectionConfig->toArray();
+ $flattened[$sectionName] = & $topLevel[$sectionName];
+ } elseif (isset($flattened[$parentName])) {
+ $flattened[$parentName]['children'][$sectionName] = $sectionConfig->toArray();
+ $flattened[$sectionName] = & $flattened[$parentName]['children'][$sectionName];
+ } else {
+ $orphans[$parentName][$sectionName] = $sectionConfig->toArray();
+ $flattened[$sectionName] = & $orphans[$parentName][$sectionName];
+ }
+ }
+
+ do {
+ $match = false;
+ foreach ($orphans as $parentName => $children) {
+ if (isset($flattened[$parentName])) {
+ if (isset($flattened[$parentName]['children'])) {
+ $flattened[$parentName]['children'] = array_merge(
+ $flattened[$parentName]['children'],
+ $children
+ );
+ } else {
+ $flattened[$parentName]['children'] = $children;
+ }
+
+ unset($orphans[$parentName]);
+ $match = true;
+ }
+ }
+ } while ($match && !empty($orphans));
+
+ if (! empty($orphans)) {
+ throw new ConfigurationError(
+ t(
+ 'Failed to fully parse navigation configuration. Ensure that'
+ . ' all referenced parents are existing navigation items: %s'
+ ),
+ join(', ', array_keys($orphans))
+ );
+ }
+
+ return static::fromArray($topLevel);
+ }
+
+ /**
+ * Create and return a new set of navigation items for the given array
+ *
+ * @param array $array
+ *
+ * @return Navigation
+ */
+ public static function fromArray(array $array)
+ {
+ $navigation = new static();
+ foreach ($array as $name => $properties) {
+ $navigation->addItem((string) $name, $properties);
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Return this navigation rendered to HTML
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Navigation/NavigationItem.php b/library/Icinga/Web/Navigation/NavigationItem.php
new file mode 100644
index 0000000..8aaf7b8
--- /dev/null
+++ b/library/Icinga/Web/Navigation/NavigationItem.php
@@ -0,0 +1,948 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use Exception;
+use Icinga\Authentication\Auth;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+use Icinga\Web\Url;
+use Traversable;
+
+/**
+ * A navigation item
+ */
+class NavigationItem implements IteratorAggregate
+{
+ /**
+ * Alternative markup element for items without a url
+ *
+ * @var string
+ */
+ const LINK_ALTERNATIVE = 'span';
+
+ /**
+ * The class namespace where to locate navigation type renderer classes
+ */
+ const RENDERER_NS = 'Web\\Navigation\\Renderer';
+
+ /**
+ * Whether this item is active
+ *
+ * @var bool
+ */
+ protected $active;
+
+ /**
+ * Whether this item is selected
+ *
+ * @var bool
+ */
+ protected $selected;
+
+ /**
+ * The CSS class used for the outer li element
+ *
+ * @var string
+ */
+ protected $cssClass;
+
+ /**
+ * This item's priority
+ *
+ * The priority defines when the item is rendered in relation to its parent's childs.
+ *
+ * @var int
+ */
+ protected $priority;
+
+ /**
+ * The attributes of this item's element
+ *
+ * @var array
+ */
+ protected $attributes;
+
+ /**
+ * This item's children
+ *
+ * @var Navigation
+ */
+ protected $children;
+
+ /**
+ * This item's icon
+ *
+ * @var string
+ */
+ protected $icon;
+
+ /**
+ * This item's name
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * This item's label
+ *
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * The item's description
+ *
+ * @var string
+ */
+ protected $description;
+
+ /**
+ * This item's parent
+ *
+ * @var NavigationItem
+ */
+ protected $parent;
+
+ /**
+ * This item's url
+ *
+ * @var Url
+ */
+ protected $url;
+
+ /**
+ * This item's url target
+ *
+ * @var string
+ */
+ protected $target;
+
+ /**
+ * Additional parameters for this item's url
+ *
+ * @var array
+ */
+ protected $urlParameters;
+
+ /**
+ * This item's renderer
+ *
+ * @var NavigationItemRenderer
+ */
+ protected $renderer;
+
+ /**
+ * Whether to render this item
+ *
+ * @var bool
+ */
+ protected $render;
+
+ /**
+ * Create a new NavigationItem
+ *
+ * @param string $name
+ * @param array $properties
+ */
+ public function __construct($name, array $properties = null)
+ {
+ $this->setName($name);
+ $this->children = new Navigation();
+
+ if (! empty($properties)) {
+ $this->setProperties($properties);
+ }
+
+ $this->init();
+ }
+
+ /**
+ * Initialize this NavigationItem
+ */
+ public function init()
+ {
+ }
+
+ /**
+ * @return Navigation
+ */
+ public function getIterator(): Traversable
+ {
+ return $this->getChildren();
+ }
+
+ /**
+ * Return whether this item is active
+ *
+ * @return bool
+ */
+ public function getActive()
+ {
+ if ($this->active === null) {
+ $this->active = false;
+ if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) {
+ $this->setActive();
+ } elseif ($this->hasChildren()) {
+ foreach ($this->getChildren() as $item) {
+ /** @var NavigationItem $item */
+ if ($item->getActive()) {
+ // Do nothing, a true active state is automatically passed to all parents
+ }
+ }
+ }
+ }
+
+ return $this->active;
+ }
+
+ /**
+ * Set whether this item is active
+ *
+ * If it's active and has a parent, the parent gets activated as well.
+ *
+ * @param bool $active
+ *
+ * @return $this
+ */
+ public function setActive($active = true)
+ {
+ $this->active = (bool) $active;
+ if ($this->active && $this->getParent() !== null) {
+ $this->getParent()->setActive();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether this item is selected
+ *
+ * @return bool
+ */
+ public function getSelected()
+ {
+ if ($this->selected === null) {
+ $this->active = false;
+ if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) {
+ $this->setSelected();
+ }
+ }
+
+ return $this->selected;
+ }
+
+ /**
+ * Set whether this item is active
+ *
+ * If it's active and has a parent, the parent gets activated as well.
+ *
+ * @param bool $selected
+ *
+ * @return $this
+ */
+ public function setSelected($selected = true)
+ {
+ $this->selected = (bool) $selected;
+
+ return $this;
+ }
+
+ /**
+ * Get the CSS class used for the outer li element
+ *
+ * @return string
+ */
+ public function getCssClass()
+ {
+ return $this->cssClass;
+ }
+
+ /**
+ * Set the CSS class to use for the outer li element
+ *
+ * @param string $class
+ *
+ * @return $this
+ */
+ public function setCssClass($class)
+ {
+ $this->cssClass = (string) $class;
+ return $this;
+ }
+
+ /**
+ * Return this item's priority
+ *
+ * @return int
+ */
+ public function getPriority()
+ {
+ return $this->priority !== null ? $this->priority : 100;
+ }
+
+ /**
+ * Set this item's priority
+ *
+ * @param int $priority
+ *
+ * @return $this
+ */
+ public function setPriority($priority)
+ {
+ $this->priority = (int) $priority;
+ return $this;
+ }
+
+ /**
+ * Return the value of the given element attribute
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getAttribute($name, $default = null)
+ {
+ $attributes = $this->getAttributes();
+ return array_key_exists($name, $attributes) ? $attributes[$name] : $default;
+ }
+
+ /**
+ * Set the value of the given element attribute
+ *
+ * @param string $name
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function setAttribute($name, $value)
+ {
+ $this->attributes[$name] = $value;
+ return $this;
+ }
+
+ /**
+ * Return the attributes of this item's element
+ *
+ * @return array
+ */
+ public function getAttributes()
+ {
+ return $this->attributes ?: array();
+ }
+
+ /**
+ * Set the attributes of this item's element
+ *
+ * @param array $attributes
+ *
+ * @return $this
+ */
+ public function setAttributes(array $attributes)
+ {
+ $this->attributes = $attributes;
+ return $this;
+ }
+
+ /**
+ * Add a child to this item
+ *
+ * If the child is active this item gets activated as well.
+ *
+ * @param NavigationItem $child
+ *
+ * @return $this
+ */
+ public function addChild(NavigationItem $child)
+ {
+ $this->getChildren()->addItem($child->setParent($this));
+ if ($child->getActive()) {
+ $this->setActive();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return this item's children
+ *
+ * @return Navigation
+ */
+ public function getChildren()
+ {
+ return $this->children;
+ }
+
+ /**
+ * Return whether this item has any children
+ *
+ * @return bool
+ */
+ public function hasChildren()
+ {
+ return ! $this->getChildren()->isEmpty();
+ }
+
+ /**
+ * Set this item's children
+ *
+ * @param array|Navigation $children
+ *
+ * @return $this
+ */
+ public function setChildren($children)
+ {
+ if (is_array($children)) {
+ $children = Navigation::fromArray($children);
+ } elseif (! $children instanceof Navigation) {
+ throw new InvalidArgumentException('Argument $children must be of type array or Navigation');
+ }
+
+ foreach ($children as $item) {
+ $item->setParent($this);
+ }
+
+ $this->children = $children;
+ return $this;
+ }
+
+ /**
+ * Return this item's icon
+ *
+ * @return string
+ */
+ public function getIcon()
+ {
+ return $this->icon;
+ }
+
+ /**
+ * Set this item's icon
+ *
+ * @param string $icon
+ *
+ * @return $this
+ */
+ public function setIcon($icon)
+ {
+ $this->icon = $icon;
+ return $this;
+ }
+
+ /**
+ * Return this item's name escaped with only ASCII chars and/or digits
+ *
+ * @return string
+ */
+ protected function getEscapedName()
+ {
+ return preg_replace('~[^a-zA-Z0-9]~', '_', $this->getName());
+ }
+
+ /**
+ * Return a unique version of this item's name
+ *
+ * @return string
+ */
+ public function getUniqueName()
+ {
+ if ($this->getParent() === null) {
+ return 'navigation-' . $this->getEscapedName();
+ }
+
+ return $this->getParent()->getUniqueName() . '-' . $this->getEscapedName();
+ }
+
+ /**
+ * Return this item's name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set this item's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Set this item's parent
+ *
+ * @param NavigationItem $parent
+ *
+ * @return $this
+ */
+ public function setParent(NavigationItem $parent)
+ {
+ $this->parent = $parent;
+ return $this;
+ }
+
+ /**
+ * Return this item's parent
+ *
+ * @return NavigationItem
+ */
+ public function getParent()
+ {
+ return $this->parent;
+ }
+
+ /**
+ * Return this item's label
+ *
+ * @return string
+ */
+ public function getLabel()
+ {
+ return $this->label !== null ? $this->label : $this->getName();
+ }
+
+ /**
+ * Set this item's label
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setLabel($label)
+ {
+ $this->label = $label;
+ return $this;
+ }
+
+ /**
+ * Get the item's description
+ *
+ * @return string
+ */
+ public function getDescription()
+ {
+ return $this->description;
+ }
+
+ /**
+ * Set the item's description
+ *
+ * @param string $description
+ *
+ * @return $this
+ */
+ public function setDescription($description)
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ /**
+ * Set this item's url target
+ *
+ * @param string $target
+ *
+ * @return $this
+ */
+ public function setTarget($target)
+ {
+ $this->target = $target;
+ return $this;
+ }
+
+ /**
+ * Return this item's url target
+ *
+ * @return string
+ */
+ public function getTarget()
+ {
+ return $this->target;
+ }
+
+ /**
+ * Return this item's url
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ if ($this->url === null && $this->hasChildren()) {
+ $this->setUrl(Url::fromPath('navigation/dashboard', array('name' => strtolower($this->getName()))));
+ }
+
+ return $this->url;
+ }
+
+ /**
+ * Set this item's url
+ *
+ * @param Url|string $url
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the given url is neither of type
+ */
+ public function setUrl($url)
+ {
+ if (is_string($url)) {
+ $url = Url::fromPath($this->resolveMacros($url));
+ } elseif ($url instanceof Url) {
+ $url = Url::fromPath($this->resolveMacros($url->getAbsoluteUrl()));
+ } else {
+ throw new InvalidArgumentException('Argument $url must be of type string or Url');
+ }
+
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Return the value of the given url parameter
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getUrlParameter($name, $default = null)
+ {
+ $parameters = $this->getUrlParameters();
+ return isset($parameters[$name]) ? $parameters[$name] : $default;
+ }
+
+ /**
+ * Set the value of the given url parameter
+ *
+ * @param string $name
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function setUrlParameter($name, $value)
+ {
+ $this->urlParameters[$name] = $value;
+ return $this;
+ }
+
+ /**
+ * Return all additional parameters for this item's url
+ *
+ * @return array
+ */
+ public function getUrlParameters()
+ {
+ return $this->urlParameters ?: array();
+ }
+
+ /**
+ * Set additional parameters for this item's url
+ *
+ * @param array $urlParameters
+ *
+ * @return $this
+ */
+ public function setUrlParameters(array $urlParameters)
+ {
+ $this->urlParameters = $urlParameters;
+ return $this;
+ }
+
+ /**
+ * Set this item's properties
+ *
+ * Unknown properties (no matching setter) are considered as element attributes.
+ *
+ * @param array $properties
+ *
+ * @return $this
+ */
+ public function setProperties(array $properties)
+ {
+ foreach ($properties as $name => $value) {
+ $setter = 'set' . ucfirst($name);
+ if (method_exists($this, $setter)) {
+ $this->$setter($value);
+ } else {
+ $this->setAttribute($name, $value);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Merge this item with the given one
+ *
+ * @param NavigationItem $item
+ *
+ * @return $this
+ */
+ public function merge(NavigationItem $item)
+ {
+ if ($this->conflictsWith($item)) {
+ throw new ProgrammingError('Cannot merge, conflict detected.');
+ }
+
+ if ($this->priority === null) {
+ $priority = $item->getPriority();
+ if ($priority !== 100) {
+ $this->setPriority($priority);
+ }
+ }
+
+ if (! $this->getIcon()) {
+ $this->setIcon($item->getIcon());
+ }
+
+ if ($this->getLabel() === $this->getName() && $item->getLabel() !== $item->getName()) {
+ $this->setLabel($item->getLabel());
+ }
+
+ if ($this->target === null && ($target = $item->getTarget()) !== null) {
+ $this->setTarget($target);
+ }
+
+ if ($this->renderer === null) {
+ $renderer = $item->getRenderer();
+ if (get_class($renderer) !== 'NavigationItemRenderer') {
+ $this->setRenderer($renderer);
+ }
+ }
+
+ foreach ($item->getAttributes() as $name => $value) {
+ $this->setAttribute($name, $value);
+ }
+
+ foreach ($item->getUrlParameters() as $name => $value) {
+ $this->setUrlParameter($name, $value);
+ }
+
+ if ($item->hasChildren()) {
+ $this->getChildren()->merge($item->getChildren());
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether it's possible to merge this item with the given one
+ *
+ * @param NavigationItem $item
+ *
+ * @return bool
+ */
+ public function conflictsWith(NavigationItem $item)
+ {
+ if (! $item instanceof $this) {
+ return true;
+ }
+
+ if ($this->getUrl() === null || $item->getUrl() === null) {
+ return false;
+ }
+
+ return !$this->getUrl()->matches($item->getUrl());
+ }
+
+ /**
+ * Create and return the given renderer
+ *
+ * @param string|array $name
+ *
+ * @return NavigationItemRenderer
+ */
+ protected function createRenderer($name)
+ {
+ if (is_array($name)) {
+ $options = array_splice($name, 1);
+ $name = $name[0];
+ } else {
+ $options = array();
+ }
+
+ $renderer = null;
+ $classPath = null;
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ $classPath = 'Icinga\\Module\\' . ucfirst($module->getName()) . '\\' . static::RENDERER_NS . '\\' . $name;
+ if (class_exists($classPath)) {
+ $renderer = new $classPath($options);
+ break;
+ }
+ }
+
+ if ($renderer === null) {
+ $classPath = 'Icinga\\' . static::RENDERER_NS . '\\' . $name;
+ if (class_exists($classPath)) {
+ $renderer = new $classPath($options);
+ }
+ }
+
+ if ($renderer === null) {
+ throw new ProgrammingError(
+ 'Cannot find renderer "%s" for navigation item "%s"',
+ $name,
+ $this->getName()
+ );
+ } elseif (! $renderer instanceof NavigationItemRenderer) {
+ throw new ProgrammingError('Class %s must inherit from NavigationItemRenderer', $classPath);
+ }
+
+ return $renderer;
+ }
+
+ /**
+ * Set this item's renderer
+ *
+ * @param string|array|NavigationItemRenderer $renderer
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the $renderer argument is neither a string nor a NavigationItemRenderer
+ */
+ public function setRenderer($renderer)
+ {
+ if (is_string($renderer) || is_array($renderer)) {
+ $renderer = $this->createRenderer($renderer);
+ } elseif (! $renderer instanceof NavigationItemRenderer) {
+ throw new InvalidArgumentException(
+ 'Argument $renderer must be of type string, array or NavigationItemRenderer'
+ );
+ }
+
+ $this->renderer = $renderer;
+ return $this;
+ }
+
+ /**
+ * Return this item's renderer
+ *
+ * @return NavigationItemRenderer
+ */
+ public function getRenderer()
+ {
+ if ($this->renderer === null) {
+ $this->setRenderer('NavigationItemRenderer');
+ }
+
+ return $this->renderer;
+ }
+
+ /**
+ * Set whether this item should be rendered
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setRender($state = true)
+ {
+ $this->render = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether this item should be rendered
+ *
+ * @return bool
+ */
+ public function getRender()
+ {
+ if ($this->render === null) {
+ return $this->getUrl() !== null;
+ }
+
+ return $this->render;
+ }
+
+ /**
+ * Return whether this item should be rendered
+ *
+ * Alias for NavigationItem::getRender().
+ *
+ * @return bool
+ */
+ public function shouldRender()
+ {
+ return $this->getRender();
+ }
+
+ /**
+ * Return this item rendered to HTML
+ *
+ * @return string
+ */
+ public function render()
+ {
+ try {
+ return $this->getRenderer()->setItem($this)->render();
+ } catch (Exception $e) {
+ Logger::error(
+ 'Could not invoke custom navigation item renderer. %s in %s:%d with message: %s',
+ get_class($e),
+ $e->getFile(),
+ $e->getLine(),
+ $e->getMessage()
+ );
+
+ $renderer = new NavigationItemRenderer();
+ return $renderer->render($this);
+ }
+ }
+
+ /**
+ * Return this item rendered to HTML
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+
+ /**
+ * Resolve all macros in the given URL
+ *
+ * @param string $url
+ *
+ * @return string
+ */
+ protected function resolveMacros($url)
+ {
+ if (strpos($url, '$') === false) {
+ return $url;
+ }
+
+ $macros = [];
+ if (Auth::getInstance()->isAuthenticated()) {
+ $macros['$user.local_name$'] = Auth::getInstance()->getUser()->getLocalUsername();
+ }
+ if (! empty($macros)) {
+ $url = str_replace(array_keys($macros), array_values($macros), $url);
+ }
+
+ return $url;
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php
new file mode 100644
index 0000000..8510f70
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php
@@ -0,0 +1,139 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Icinga\Web\Navigation\NavigationItem;
+
+/**
+ * Abstract base class for a NavigationItem with a status badge
+ */
+abstract class BadgeNavigationItemRenderer extends NavigationItemRenderer
+{
+ const STATE_OK = 'ok';
+ const STATE_CRITICAL = 'critical';
+ const STATE_WARNING = 'warning';
+ const STATE_PENDING = 'pending';
+ const STATE_UNKNOWN = 'unknown';
+
+ /**
+ * The tooltip text for the badge
+ *
+ * @var string
+ */
+ protected $title;
+
+ /**
+ * The state identifier being used
+ *
+ * The state identifier defines the background color of the badge.
+ *
+ * @var string
+ */
+ protected $state;
+
+ /**
+ * Set the tooltip text for the badge
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * Return the tooltip text for the badge
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ /**
+ * Set the state identifier to use
+ *
+ * @param string $state
+ *
+ * @return $this
+ */
+ public function setState($state)
+ {
+ $this->state = $state;
+ return $this;
+ }
+
+ /**
+ * Return the state identifier to use
+ *
+ * @return string
+ */
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ /**
+ * Return the amount of items represented by the badge
+ *
+ * @return int
+ */
+ abstract public function getCount();
+
+ /**
+ * Render the given navigation item as HTML anchor with a badge
+ *
+ * @param NavigationItem $item
+ *
+ * @return string
+ */
+ public function render(NavigationItem $item = null)
+ {
+ if ($item === null) {
+ $item = $this->getItem();
+ }
+
+ $cssClass = '';
+ if ($item->getCssClass() !== null) {
+ $cssClass = ' ' . $item->getCssClass();
+ }
+
+ $item->setCssClass('badge-nav-item' . $cssClass);
+ $this->setEscapeLabel(false);
+ $label = $this->view()->escape($item->getLabel());
+ $item->setLabel($this->renderBadge() . $label);
+ $html = parent::render($item);
+ return $html;
+ }
+
+ /**
+ * Render the badge
+ *
+ * @return string
+ */
+ protected function renderBadge()
+ {
+ if ($count = $this->getCount()) {
+ if ($count > 1000000) {
+ $count = round($count, -6) / 1000000 . 'M';
+ } elseif ($count > 1000) {
+ $count = round($count, -3) / 1000 . 'k';
+ }
+
+ $view = $this->view();
+ return sprintf(
+ '<span title="%s" class="badge state-%s">%s</span>',
+ $view->escape($this->getTitle()),
+ $view->escape($this->getState()),
+ $count
+ );
+ }
+
+ return '';
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php
new file mode 100644
index 0000000..577895b
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php
@@ -0,0 +1,44 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Icinga\Application\Hook\HealthHook;
+
+class HealthNavigationRenderer extends BadgeNavigationItemRenderer
+{
+ public function getCount()
+ {
+ $count = 0;
+ $title = null;
+ $worstState = null;
+ foreach (HealthHook::collectHealthData()->select() as $result) {
+ if ($worstState === null || $result->state > $worstState) {
+ $worstState = $result->state;
+ $title = $result->message;
+ $count = 1;
+ } elseif ($worstState === $result->state) {
+ $count++;
+ }
+ }
+
+ switch ($worstState) {
+ case HealthHook::STATE_OK:
+ $count = 0;
+ break;
+ case HealthHook::STATE_WARNING:
+ $this->state = self::STATE_WARNING;
+ break;
+ case HealthHook::STATE_CRITICAL:
+ $this->state = self::STATE_CRITICAL;
+ break;
+ case HealthHook::STATE_UNKNOWN:
+ $this->state = self::STATE_UNKNOWN;
+ break;
+ }
+
+ $this->title = $title;
+
+ return $count;
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php
new file mode 100644
index 0000000..51136ff
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php
@@ -0,0 +1,235 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Util\StringHelper;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Url;
+use Icinga\Web\View;
+
+/**
+ * NavigationItemRenderer
+ */
+class NavigationItemRenderer
+{
+ /**
+ * View
+ *
+ * @var View
+ */
+ protected $view;
+
+ /**
+ * The item being rendered
+ *
+ * @var NavigationItem
+ */
+ protected $item;
+
+ /**
+ * Internal link targets provided by Icinga Web 2
+ *
+ * @var array
+ */
+ protected $internalLinkTargets;
+
+ /**
+ * Whether to escape the label
+ *
+ * @var bool
+ */
+ protected $escapeLabel;
+
+ /**
+ * Create a new NavigationItemRenderer
+ *
+ * @param array $options
+ */
+ public function __construct(array $options = null)
+ {
+ if (! empty($options)) {
+ $this->setOptions($options);
+ }
+
+ $this->internalLinkTargets = array('_main', '_self', '_next');
+ $this->init();
+ }
+
+ /**
+ * Initialize this renderer
+ */
+ public function init()
+ {
+ }
+
+ /**
+ * Set the given options
+ *
+ * @param array $options
+ *
+ * @return $this
+ */
+ public function setOptions(array $options)
+ {
+ foreach ($options as $name => $value) {
+ $setter = 'set' . StringHelper::cname($name);
+ if (method_exists($this, $setter)) {
+ $this->$setter($value);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set the view
+ *
+ * @param View $view
+ *
+ * @return $this
+ */
+ public function setView(View $view)
+ {
+ $this->view = $view;
+ return $this;
+ }
+
+ /**
+ * Return the view
+ *
+ * @return View
+ */
+ public function view()
+ {
+ if ($this->view === null) {
+ $this->setView(Icinga::app()->getViewRenderer()->view);
+ }
+
+ return $this->view;
+ }
+
+ /**
+ * Set the navigation item to render
+ *
+ * @param NavigationItem $item
+ *
+ * @return $this
+ */
+ public function setItem(NavigationItem $item)
+ {
+ $this->item = $item;
+ return $this;
+ }
+
+ /**
+ * Return the navigation item being rendered
+ *
+ * @return NavigationItem
+ */
+ public function getItem()
+ {
+ return $this->item;
+ }
+
+ /**
+ * Set whether to escape the label
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setEscapeLabel($state = true)
+ {
+ $this->escapeLabel = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether to escape the label
+ *
+ * @return bool
+ */
+ public function getEscapeLabel()
+ {
+ return $this->escapeLabel !== null ? $this->escapeLabel : true;
+ }
+
+ /**
+ * Render the given navigation item as HTML anchor
+ *
+ * @param NavigationItem $item
+ *
+ * @return string
+ */
+ public function render(NavigationItem $item = null)
+ {
+ if ($item !== null) {
+ $this->setItem($item);
+ } elseif (($item = $this->getItem()) === null) {
+ throw new ProgrammingError(
+ 'Cannot render nothing. Pass the item to render as part'
+ . ' of the call to render() or set it with setItem()'
+ );
+ }
+
+ $label = $this->getEscapeLabel()
+ ? $this->view()->escape($item->getLabel())
+ : $item->getLabel();
+ if (($icon = $item->getIcon()) !== null) {
+ $label = $this->view()->icon($icon) . $label;
+ } elseif ($item->getName()) {
+ $firstLetter = $item->getName()[0];
+ $label = $this->view()->icon('letter', null, ['data-letter' => strtolower($firstLetter)]) . $label;
+ }
+
+ if (($url = $item->getUrl()) !== null) {
+ $url->overwriteParams($item->getUrlParameters());
+
+ $target = $item->getTarget();
+ if ($url->isExternal() && (!$target || in_array($target, $this->internalLinkTargets, true))) {
+ $url = Url::fromPath('iframe', array('url' => $url));
+ }
+
+ $content = sprintf(
+ '<a%s href="%s"%s>%s</a>',
+ $this->view()->propertiesToString($item->getAttributes()),
+ $this->view()->escape($url->getAbsoluteUrl('&')),
+ $this->renderTargetAttribute(),
+ $label
+ );
+ } elseif ($label) {
+ $content = sprintf(
+ '<%1$s%2$s>%3$s</%1$s>',
+ $item::LINK_ALTERNATIVE,
+ $this->view()->propertiesToString($item->getAttributes()),
+ $label
+ );
+ } else {
+ $content = '';
+ }
+
+ return $content;
+ }
+
+ /**
+ * Render and return the attribute to provide a non-default target for the url
+ *
+ * @return string
+ */
+ protected function renderTargetAttribute()
+ {
+ $target = $this->getItem()->getTarget();
+ if ($target === null || $this->getItem()->getUrl()->getAbsoluteUrl() == '#') {
+ return '';
+ }
+
+ if (! in_array($target, $this->internalLinkTargets, true)) {
+ return ' target="' . $this->view()->escape($target) . '"';
+ }
+
+ return ' data-base-target="' . $target . '"';
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php
new file mode 100644
index 0000000..00c0f9a
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php
@@ -0,0 +1,356 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use ArrayIterator;
+use Exception;
+use RecursiveIterator;
+use Icinga\Application\Icinga;
+use Icinga\Exception\IcingaException;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\View;
+
+/**
+ * Renderer for single level navigation
+ */
+class NavigationRenderer implements RecursiveIterator, NavigationRendererInterface
+{
+ /**
+ * The tag used for the outer element
+ *
+ * @var string
+ */
+ protected $elementTag;
+
+ /**
+ * The CSS class used for the outer element
+ *
+ * @var string
+ */
+ protected $cssClass;
+
+ /**
+ * The navigation's heading text
+ *
+ * @var string
+ */
+ protected $heading;
+
+ /**
+ * The content rendered so far
+ *
+ * @var array
+ */
+ protected $content;
+
+ /**
+ * Whether to skip rendering the outer element
+ *
+ * @var bool
+ */
+ protected $skipOuterElement;
+
+ /**
+ * The navigation's iterator
+ *
+ * @var ArrayIterator
+ */
+ protected $iterator;
+
+ /**
+ * The navigation
+ *
+ * @var Navigation
+ */
+ protected $navigation;
+
+ /**
+ * View
+ *
+ * @var View
+ */
+ protected $view;
+
+ /**
+ * Create a new NavigationRenderer
+ *
+ * @param Navigation $navigation
+ * @param bool $skipOuterElement
+ */
+ public function __construct(Navigation $navigation, $skipOuterElement = false)
+ {
+ $this->skipOuterElement = $skipOuterElement;
+ $this->iterator = $navigation->getIterator();
+ $this->navigation = $navigation;
+ $this->content = array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setElementTag($tag)
+ {
+ $this->elementTag = $tag;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getElementTag()
+ {
+ return $this->elementTag ?: static::OUTER_ELEMENT_TAG;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCssClass($class)
+ {
+ $this->cssClass = $class;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCssClass()
+ {
+ return $this->cssClass;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setHeading($heading)
+ {
+ $this->heading = $heading;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getHeading()
+ {
+ return $this->heading;
+ }
+
+ /**
+ * Return the view
+ *
+ * @return View
+ */
+ public function view()
+ {
+ if ($this->view === null) {
+ $this->setView(Icinga::app()->getViewRenderer()->view);
+ }
+
+ return $this->view;
+ }
+
+ /**
+ * Set the view
+ *
+ * @param View $view
+ *
+ * @return $this
+ */
+ public function setView(View $view)
+ {
+ $this->view = $view;
+ return $this;
+ }
+
+ public function getChildren(): NavigationRenderer
+ {
+ return new static($this->current()->getChildren(), $this->skipOuterElement);
+ }
+
+ public function hasChildren(): bool
+ {
+ return $this->current()->hasChildren();
+ }
+
+ public function current(): NavigationItem
+ {
+ return $this->iterator->current();
+ }
+
+ public function key(): int
+ {
+ return $this->iterator->key();
+ }
+
+ public function next(): void
+ {
+ $this->iterator->next();
+ }
+
+ public function rewind(): void
+ {
+ $this->iterator->rewind();
+ if (! $this->skipOuterElement) {
+ $this->content[] = $this->beginMarkup();
+ }
+ }
+
+ public function valid(): bool
+ {
+ $valid = $this->iterator->valid();
+ if (! $this->skipOuterElement && !$valid) {
+ $this->content[] = $this->endMarkup();
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Return the opening markup for the navigation
+ *
+ * @return string
+ */
+ public function beginMarkup()
+ {
+ $content = array();
+ $content[] = sprintf(
+ '<%s%s role="navigation">',
+ $this->getElementTag(),
+ $this->getCssClass() !== null ? ' class="' . $this->getCssClass() . '"' : ''
+ );
+ if (($heading = $this->getHeading()) !== null) {
+ $content[] = sprintf(
+ '<h%1$d id="navigation" class="sr-only" tabindex="-1">%2$s</h%1$d>',
+ static::HEADING_RANK,
+ $this->view()->escape($heading)
+ );
+ }
+ $content[] = $this->beginChildrenMarkup();
+ return join("\n", $content);
+ }
+
+ /**
+ * Return the closing markup for the navigation
+ *
+ * @return string
+ */
+ public function endMarkup()
+ {
+ $content = array();
+ $content[] = $this->endChildrenMarkup();
+ $content[] = '</' . $this->getElementTag() . '>';
+ return join("\n", $content);
+ }
+
+ /**
+ * Return the opening markup for multiple navigation items
+ *
+ * @param int $level
+ *
+ * @return string
+ */
+ public function beginChildrenMarkup($level = 1)
+ {
+ $cssClass = array(static::CSS_CLASS_NAV);
+ if ($this->navigation->getLayout() === Navigation::LAYOUT_TABS) {
+ $cssClass[] = static::CSS_CLASS_NAV_TABS;
+ } elseif ($this->navigation->getLayout() === Navigation::LAYOUT_DROPDOWN) {
+ $cssClass[] = static::CSS_CLASS_NAV_DROPDOWN;
+ }
+
+ $cssClass[] = 'nav-level-' . $level;
+
+ return '<ul class="' . join(' ', $cssClass) . '">';
+ }
+
+ /**
+ * Return the closing markup for multiple navigation items
+ *
+ * @return string
+ */
+ public function endChildrenMarkup()
+ {
+ return '</ul>';
+ }
+
+ /**
+ * Return the opening markup for the given navigation item
+ *
+ * @param NavigationItem $item
+ *
+ * @return string
+ */
+ public function beginItemMarkup(NavigationItem $item)
+ {
+ $cssClasses = array(static::CSS_CLASS_ITEM);
+
+ if ($item->hasChildren() && $item->getChildren()->getLayout() === Navigation::LAYOUT_DROPDOWN) {
+ $cssClasses[] = static::CSS_CLASS_DROPDOWN;
+ $item
+ ->setAttribute('class', static::CSS_CLASS_DROPDOWN_TOGGLE)
+ ->setIcon(static::DROPDOWN_TOGGLE_ICON)
+ ->setUrl('#');
+ }
+
+ if ($item->getActive()) {
+ $cssClasses[] = static::CSS_CLASS_ACTIVE;
+ }
+
+ if ($item->getSelected()) {
+ $cssClasses[] = static::CSS_CLASS_SELECTED;
+ }
+
+ if ($cssClass = $item->getCssClass()) {
+ $cssClasses[] = $cssClass;
+ }
+
+ $content = sprintf(
+ '<li class="%s">',
+ join(' ', $cssClasses)
+ );
+ return $content;
+ }
+
+ /**
+ * Return the closing markup for a navigation item
+ *
+ * @return string
+ */
+ public function endItemMarkup()
+ {
+ return '</li>';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ foreach ($this as $item) {
+ /** @var NavigationItem $item */
+ if ($item->shouldRender()) {
+ $content = $item->render();
+ $this->content[] = $this->beginItemMarkup($item);
+ $this->content[] = $content;
+ $this->content[] = $this->endItemMarkup();
+ }
+ }
+
+ return join("\n", $this->content);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php
new file mode 100644
index 0000000..4495b73
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php
@@ -0,0 +1,142 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+/**
+ * Interface for navigation renderers
+ */
+interface NavigationRendererInterface
+{
+ /**
+ * CSS class for items
+ *
+ * @var string
+ */
+ const CSS_CLASS_ITEM = 'nav-item';
+
+ /**
+ * CSS class for active items
+ *
+ * @var string
+ */
+ const CSS_CLASS_ACTIVE = 'active';
+
+ /**
+ * CSS class for selected items
+ *
+ * @var string
+ */
+ const CSS_CLASS_SELECTED = 'selected';
+
+ /**
+ * CSS class for dropdown items
+ *
+ * @var string
+ */
+ const CSS_CLASS_DROPDOWN = 'dropdown-nav-item';
+
+ /**
+ * CSS class for a dropdown item's trigger
+ *
+ * @var string
+ */
+ const CSS_CLASS_DROPDOWN_TOGGLE = 'dropdown-toggle';
+
+ /**
+ * CSS class for the ul element
+ *
+ * @var string
+ */
+ const CSS_CLASS_NAV = 'nav';
+
+ /**
+ * CSS class for the ul element with dropdown layout
+ *
+ * @var string
+ */
+ const CSS_CLASS_NAV_DROPDOWN = 'dropdown-nav';
+
+ /**
+ * CSS class for the ul element with tabs layout
+ *
+ * @var string
+ */
+ const CSS_CLASS_NAV_TABS = 'tab-nav';
+
+ /**
+ * Icon for a dropdown item's trigger
+ *
+ * @var string
+ */
+ const DROPDOWN_TOGGLE_ICON = 'menu';
+
+ /**
+ * Default tag for the outer element the navigation will be wrapped with
+ *
+ * @var string
+ */
+ const OUTER_ELEMENT_TAG = 'div';
+
+ /**
+ * The heading's rank
+ *
+ * @var int
+ */
+ const HEADING_RANK = 1;
+
+ /**
+ * Set the tag for the outer element the navigation is wrapped with
+ *
+ * @param string $tag
+ *
+ * @return $this
+ */
+ public function setElementTag($tag);
+
+ /**
+ * Return the tag for the outer element the navigation is wrapped with
+ *
+ * @return string
+ */
+ public function getElementTag();
+
+ /**
+ * Set the CSS class to use for the outer element
+ *
+ * @param string $class
+ *
+ * @return $this
+ */
+ public function setCssClass($class);
+
+ /**
+ * Get the CSS class used for the outer element
+ *
+ * @return string
+ */
+ public function getCssClass();
+
+ /**
+ * Set the navigation's heading text
+ *
+ * @param string $heading
+ *
+ * @return $this
+ */
+ public function setHeading($heading);
+
+ /**
+ * Return the navigation's heading text
+ *
+ * @return string
+ */
+ public function getHeading();
+
+ /**
+ * Return the navigation rendered to HTML
+ *
+ * @return string
+ */
+ public function render();
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php
new file mode 100644
index 0000000..315c2aa
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php
@@ -0,0 +1,186 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Exception;
+use RecursiveIteratorIterator;
+use Icinga\Exception\IcingaException;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+
+/**
+ * Renderer for multi level navigation
+ *
+ * @method NavigationRenderer getInnerIterator() {
+ * {@inheritdoc}
+ * }
+ */
+class RecursiveNavigationRenderer extends RecursiveIteratorIterator implements NavigationRendererInterface
+{
+ /**
+ * The content rendered so far
+ *
+ * @var array
+ */
+ protected $content;
+
+ /**
+ * Whether to use the standard item renderer
+ *
+ * @var bool
+ */
+ protected $useStandardRenderer;
+
+ /**
+ * Create a new RecursiveNavigationRenderer
+ *
+ * @param Navigation $navigation
+ */
+ public function __construct(Navigation $navigation)
+ {
+ $this->content = array();
+ parent::__construct(
+ new NavigationRenderer($navigation, true),
+ RecursiveIteratorIterator::SELF_FIRST
+ );
+ }
+
+ /**
+ * Set whether to use the standard navigation item renderer
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setUseStandardItemRenderer($state = true)
+ {
+ $this->useStandardRenderer = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether to use the standard navigation item renderer
+ *
+ * @return bool
+ */
+ public function getUseStandardItemRenderer()
+ {
+ return $this->useStandardRenderer;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setElementTag($tag)
+ {
+ $this->getInnerIterator()->setElementTag($tag);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getElementTag()
+ {
+ return $this->getInnerIterator()->getElementTag();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCssClass($class)
+ {
+ $this->getInnerIterator()->setCssClass($class);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCssClass()
+ {
+ return $this->getInnerIterator()->getCssClass();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setHeading($heading)
+ {
+ $this->getInnerIterator()->setHeading($heading);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getHeading()
+ {
+ return $this->getInnerIterator()->getHeading();
+ }
+
+ public function beginIteration(): void
+ {
+ $this->content[] = $this->getInnerIterator()->beginMarkup();
+ }
+
+ public function endIteration(): void
+ {
+ $this->content[] = $this->getInnerIterator()->endMarkup();
+ }
+
+ public function beginChildren(): void
+ {
+ $this->content[] = $this->getInnerIterator()->beginChildrenMarkup($this->getDepth() + 1);
+ }
+
+ public function endChildren(): void
+ {
+ $this->content[] = $this->getInnerIterator()->endChildrenMarkup();
+ $this->content[] = $this->getInnerIterator()->endItemMarkup();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ foreach ($this as $item) {
+ /** @var NavigationItem $item */
+ if ($item->shouldRender()) {
+ if ($this->getDepth() > 0) {
+ $item->setIcon(null);
+ }
+ if ($this->getUseStandardItemRenderer()) {
+ $renderer = new NavigationItemRenderer();
+ $content = $renderer->render($item);
+ } else {
+ $content = $item->render();
+ }
+ $this->content[] = $this->getInnerIterator()->beginItemMarkup($item);
+
+ $this->content[] = $content;
+
+ if (! $item->hasChildren()) {
+ $this->content[] = $this->getInnerIterator()->endItemMarkup();
+ }
+ }
+ }
+
+ return join("\n", $this->content);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php
new file mode 100644
index 0000000..2916f4e
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php
@@ -0,0 +1,72 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+/**
+ * Badge renderer summing up the worst state of its children
+ */
+class SummaryNavigationItemRenderer extends BadgeNavigationItemRenderer
+{
+ /**
+ * Cached count
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * State to severity map
+ *
+ * @var array
+ */
+ protected static $stateSeverityMap = array(
+ self::STATE_OK => 0,
+ self::STATE_PENDING => 1,
+ self::STATE_UNKNOWN => 2,
+ self::STATE_WARNING => 3,
+ self::STATE_CRITICAL => 4,
+ );
+
+ /**
+ * Severity to state map
+ *
+ * @var array
+ */
+ protected static $severityStateMap = array(
+ self::STATE_OK,
+ self::STATE_PENDING,
+ self::STATE_UNKNOWN,
+ self::STATE_WARNING,
+ self::STATE_CRITICAL
+ );
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCount()
+ {
+ if ($this->count === null) {
+ $countMap = array_fill(0, 5, 0);
+ $maxSeverity = 0;
+ $titles = array();
+ foreach ($this->getItem()->getChildren() as $child) {
+ $renderer = $child->getRenderer();
+ if ($renderer instanceof BadgeNavigationItemRenderer) {
+ $count = $renderer->getCount();
+ if ($count) {
+ $severity = static::$stateSeverityMap[$renderer->getState()];
+ $countMap[$severity] += $count;
+ $titles[] = $renderer->getTitle();
+ $maxSeverity = max($maxSeverity, $severity);
+ }
+ }
+ }
+ $this->count = $countMap[$maxSeverity];
+ $this->state = static::$severityStateMap[$maxSeverity];
+ $this->title = implode('. ', $titles);
+ }
+
+ return $this->count;
+ }
+}
diff --git a/library/Icinga/Web/Notification.php b/library/Icinga/Web/Notification.php
new file mode 100644
index 0000000..6f33a32
--- /dev/null
+++ b/library/Icinga/Web/Notification.php
@@ -0,0 +1,220 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Application\Platform;
+use Icinga\Application\Logger;
+use Icinga\Web\Session;
+
+/**
+ * // @TODO(eL): Use Notification not as Singleton but within request:
+ * <code>
+ * <?php
+ * $request->[getUser()]->notify('some message', Notification::INFO);
+ * </code>
+ */
+class Notification
+{
+ /**
+ * Notification type info
+ *
+ * @var string
+ */
+ const INFO = 'info';
+
+ /**
+ * Notification type error
+ *
+ * @var string
+ */
+ const ERROR = 'error';
+
+ /**
+ * Notification type success
+ *
+ * @var string
+ */
+ const SUCCESS = 'success';
+
+ /**
+ * Notification type warning
+ *
+ * @var string
+ */
+ const WARNING = 'warning';
+
+ /**
+ * Name of the session key for notification messages
+ *
+ * @var string
+ */
+ const SESSION_KEY = 'session';
+
+ /**
+ * Singleton instance
+ *
+ * @var self
+ */
+ protected static $instance;
+
+ /**
+ * Whether the platform is CLI
+ *
+ * @var bool
+ */
+ protected $isCli = false;
+
+ /**
+ * Notification messages
+ *
+ * @var array
+ */
+ protected $messages = array();
+
+ /**
+ * Session
+ *
+ * @var Session
+ */
+ protected $session;
+
+ /**
+ * Create the notification instance
+ */
+ final private function __construct()
+ {
+ if (Platform::isCli()) {
+ $this->isCli = true;
+ return;
+ }
+
+ $this->session = Session::getSession();
+ $messages = $this->session->get(self::SESSION_KEY);
+ if (is_array($messages)) {
+ $this->messages = $messages;
+ $this->session->delete(self::SESSION_KEY);
+ $this->session->write();
+ }
+ }
+
+ /**
+ * Get the Notification instance
+ *
+ * @return Notification
+ */
+ public static function getInstance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Add info notification
+ *
+ * @param string $msg
+ */
+ public static function info($msg)
+ {
+ self::getInstance()->addMessage($msg, self::INFO);
+ }
+
+ /**
+ * Add error notification
+ *
+ * @param string $msg
+ */
+ public static function error($msg)
+ {
+ self::getInstance()->addMessage($msg, self::ERROR);
+ }
+
+ /**
+ * Add success notification
+ *
+ * @param string $msg
+ */
+ public static function success($msg)
+ {
+ self::getInstance()->addMessage($msg, self::SUCCESS);
+ }
+
+ /**
+ * Add warning notification
+ *
+ * @param string $msg
+ */
+ public static function warning($msg)
+ {
+ self::getInstance()->addMessage($msg, self::WARNING);
+ }
+
+ /**
+ * Add a notification message
+ *
+ * @param string $message
+ * @param string $type
+ */
+ protected function addMessage($message, $type = self::INFO)
+ {
+ if ($this->isCli) {
+ $msg = sprintf('[%s] %s', $type, $message);
+ switch ($type) {
+ case self::INFO:
+ case self::SUCCESS:
+ Logger::info($msg);
+ break;
+ case self::ERROR:
+ Logger::error($msg);
+ break;
+ case self::WARNING:
+ Logger::warning($msg);
+ break;
+ }
+ } else {
+ $this->messages[] = (object) array(
+ 'type' => $type,
+ 'message' => $message,
+ );
+ }
+ }
+
+ /**
+ * Pop the notification messages
+ *
+ * @return array
+ */
+ public function popMessages()
+ {
+ $messages = $this->messages;
+ $this->messages = array();
+ return $messages;
+ }
+
+ /**
+ * Get whether notification messages have been added
+ *
+ * @return bool
+ */
+ public function hasMessages()
+ {
+ return ! empty($this->messages);
+ }
+
+ /**
+ * Destroy the notification instance
+ */
+ final public function __destruct()
+ {
+ if ($this->isCli) {
+ return;
+ }
+ if ($this->hasMessages() && $this->session->get('messages') !== $this->messages) {
+ $this->session->set(self::SESSION_KEY, $this->messages);
+ $this->session->write();
+ }
+ }
+}
diff --git a/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php
new file mode 100644
index 0000000..6f103e5
--- /dev/null
+++ b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php
@@ -0,0 +1,84 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Paginator\Adapter;
+
+use Zend_Paginator_Adapter_Interface;
+use Icinga\Data\QueryInterface;
+
+class QueryAdapter implements Zend_Paginator_Adapter_Interface
+{
+ /**
+ * The query being paginated
+ *
+ * @var QueryInterface
+ */
+ protected $query;
+
+ /**
+ * Item count
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * Create a new QueryAdapter
+ *
+ * @param QueryInterface $query The query to paginate
+ */
+ public function __construct(QueryInterface $query)
+ {
+ $this->setQuery($query);
+ }
+
+ /**
+ * Set the query to paginate
+ *
+ * @param QueryInterface $query
+ *
+ * @return $this
+ */
+ public function setQuery(QueryInterface $query)
+ {
+ $this->query = $query;
+ return $this;
+ }
+
+ /**
+ * Return the query being paginated
+ *
+ * @return QueryInterface
+ */
+ public function getQuery()
+ {
+ return $this->query;
+ }
+
+ /**
+ * Fetch and return the rows in the given range of the query result
+ *
+ * @param int $offset Page offset
+ * @param int $itemCountPerPage Number of items per page
+ *
+ * @return array
+ */
+ public function getItems($offset, $itemCountPerPage)
+ {
+ return $this->query->limit($itemCountPerPage, $offset)->fetchAll();
+ }
+
+ /**
+ * Return the total number of items in the query result
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ if ($this->count === null) {
+ $this->count = $this->query->count();
+ }
+
+ return $this->count;
+ }
+}
diff --git a/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php b/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php
new file mode 100644
index 0000000..d9b2ed9
--- /dev/null
+++ b/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php
@@ -0,0 +1,78 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+/**
+ * @see Zend_Paginator_ScrollingStyle_Interface
+ */
+class Icinga_Web_Paginator_ScrollingStyle_SlidingWithBorder implements Zend_Paginator_ScrollingStyle_Interface
+{
+ /**
+ * Returns an array of "local" pages given a page number and range.
+ *
+ * @param Zend_Paginator $paginator
+ * @param integer $pageRange (Optional) Page range
+ * @return array
+ */
+ public function getPages(Zend_Paginator $paginator, $pageRange = null)
+ {
+ // This is unused
+ if ($pageRange === null) {
+ $pageRange = $paginator->getPageRange();
+ }
+
+ $pageNumber = $paginator->getCurrentPageNumber();
+ $pageCount = count($paginator);
+ $range = array();
+
+ if ($pageCount < 10) {
+ // Show all pages if we have less than 10.
+
+ for ($i = 1; $i < 10; $i++) {
+ if ($i > $pageCount) {
+ break;
+ }
+ $range[$i] = $i;
+ }
+ } else {
+ // More than 10 pages:
+
+ foreach (array(1, 2) as $i) {
+ $range[$i] = $i;
+ }
+ if ($pageNumber < 6) {
+ // We are on page 1-5 from
+ for ($i = 1; $i <= 7; $i++) {
+ $range[$i] = $i;
+ }
+ } else {
+ // Current page > 5
+ $range[] = '...';
+
+ // Less than 5 pages left
+ if (($pageCount - $pageNumber) < 5) {
+ $start = 5 - ($pageCount - $pageNumber);
+ } else {
+ $start = 1;
+ }
+
+ for ($i = $pageNumber - $start; $i < ($pageNumber + (4 - $start)); $i++) {
+ if ($i > $pageCount) {
+ break;
+ }
+ $range[$i] = $i;
+ }
+ }
+ if ($pageNumber < ($pageCount - 2)) {
+ $range[] = '...';
+ }
+
+ foreach (array($pageCount - 1, $pageCount) as $i) {
+ $range[$i] = $i;
+ }
+ }
+ if (empty($range)) {
+ $range[] = 1;
+ }
+ return $range;
+ }
+}
diff --git a/library/Icinga/Web/RememberMe.php b/library/Icinga/Web/RememberMe.php
new file mode 100644
index 0000000..1002396
--- /dev/null
+++ b/library/Icinga/Web/RememberMe.php
@@ -0,0 +1,363 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Config;
+use Icinga\Authentication\Auth;
+use Icinga\Crypt\AesCrypt;
+use Icinga\Common\Database;
+use Icinga\User;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use RuntimeException;
+
+/**
+ * Remember me component
+ *
+ * Retains credentials for 30 days by default in order to stay signed in even after the session is closed.
+ */
+class RememberMe
+{
+ use Database;
+
+ /** @var string Cookie name */
+ const COOKIE = 'icingaweb2-remember-me';
+
+ /** @var string Database table name */
+ const TABLE = 'icingaweb_rememberme';
+
+ /** @var string Encrypted password of the user */
+ protected $encryptedPassword;
+
+ /** @var string */
+ protected $username;
+
+ /** @var AesCrypt Instance for encrypting/decrypting the credentials */
+ protected $aesCrypt;
+
+ /** @var int Timestamp when the remember me cookie expires */
+ protected $expiresAt;
+
+ /**
+ * Get whether staying logged in is possible
+ *
+ * @return bool
+ */
+ public static function isSupported()
+ {
+ $self = new self();
+
+ if (! $self->hasDb()) {
+ return false;
+ }
+
+ try {
+ (new AesCrypt())->getMethod();
+ } catch (RuntimeException $_) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get whether the remember cookie is set
+ *
+ * @return bool
+ */
+ public static function hasCookie()
+ {
+ return isset($_COOKIE[static::COOKIE]);
+ }
+
+ /**
+ * Remove the database entry if exists and unset the remember me cookie from PHP's `$_COOKIE` superglobal
+ *
+ * @return Cookie The invalidation cookie which has to be sent to client in oder to remove the remember me cookie
+ */
+ public static function forget()
+ {
+ if (self::hasCookie()) {
+ $data = explode('|', $_COOKIE[static::COOKIE]);
+ $iv = base64_decode(array_pop($data));
+ (new self())->remove(bin2hex($iv));
+ }
+
+ unset($_COOKIE[static::COOKIE]);
+
+ return (new Cookie(static::COOKIE))
+ ->setHttpOnly(true)
+ ->forgetMe();
+ }
+
+ /**
+ * Create the remember me component from the remember me cookie
+ *
+ * @return static
+ */
+ public static function fromCookie()
+ {
+ $data = explode('|', $_COOKIE[static::COOKIE]);
+ $iv = base64_decode(array_pop($data));
+
+ $select = (new Select())
+ ->from(static::TABLE)
+ ->columns('*')
+ ->where(['random_iv = ?' => bin2hex($iv)]);
+
+ $rememberMe = new static();
+ $rs = $rememberMe->getDb()->select($select)->fetch();
+
+ if (! $rs) {
+ throw new RuntimeException(sprintf(
+ "No database entry found for IV '%s'",
+ bin2hex($iv)
+ ));
+ }
+
+ $rememberMe->aesCrypt = (new AesCrypt())
+ ->setKey(hex2bin($rs->passphrase))
+ ->setIV($iv);
+
+ if (count($data) > 1) {
+ $rememberMe->aesCrypt->setTag(
+ base64_decode(array_pop($data))
+ );
+ } elseif ($rememberMe->aesCrypt->isAuthenticatedEncryptionRequired()) {
+ throw new RuntimeException(
+ "The given decryption method needs a tag, but is not specified. "
+ . "You have probably updated the PHP version."
+ );
+ }
+
+ $rememberMe->username = $rs->username;
+ $rememberMe->encryptedPassword = $data[0];
+
+ return $rememberMe;
+ }
+
+ /**
+ * Create the remember me component from the given username and password
+ *
+ * @param string $username
+ * @param string $password
+ *
+ * @return static
+ */
+ public static function fromCredentials($username, $password)
+ {
+ $aesCrypt = new AesCrypt();
+ $rememberMe = new static();
+ $rememberMe->encryptedPassword = $aesCrypt->encrypt($password);
+ $rememberMe->username = $username;
+ $rememberMe->aesCrypt = $aesCrypt;
+
+ return $rememberMe;
+ }
+
+ /**
+ * Remove expired remember me information from the database
+ */
+ public static function removeExpired()
+ {
+ $rememberMe = new static();
+ if (! $rememberMe->hasDb()) {
+ return;
+ }
+
+ $rememberMe->getDb()->delete(static::TABLE, [
+ 'expires_at < NOW()'
+ ]);
+ }
+
+ /**
+ * Get the remember me cookie
+ *
+ * @return Cookie
+ */
+ public function getCookie()
+ {
+ $values = [
+ $this->encryptedPassword,
+ base64_encode($this->aesCrypt->getIV()),
+ ];
+
+ if ($this->aesCrypt->isAuthenticatedEncryptionRequired()) {
+ array_splice($values, 1, 0, base64_encode($this->aesCrypt->getTag()));
+ }
+
+ return (new Cookie(static::COOKIE))
+ ->setExpire($this->getExpiresAt())
+ ->setHttpOnly(true)
+ ->setValue(implode('|', $values));
+ }
+
+ /**
+ * Get the timestamp when the cookie expires
+ *
+ * Defaults to now plus 30 days, if not set via {@link setExpiresAt()}.
+ *
+ * @return int
+ */
+ public function getExpiresAt()
+ {
+ if ($this->expiresAt === null) {
+ $this->expiresAt = time() + 60 * 60 * 24 * 30;
+ }
+
+ return $this->expiresAt;
+ }
+
+ /**
+ * Set the timestamp when the cookie expires
+ *
+ * @param int $expiresAt
+ *
+ * @return $this
+ */
+ public function setExpiresAt($expiresAt)
+ {
+ $this->expiresAt = $expiresAt;
+
+ return $this;
+ }
+
+ /**
+ * Authenticate via the remember me cookie
+ *
+ * @return bool
+ *
+ * @throws \Icinga\Exception\AuthenticationException
+ */
+ public function authenticate()
+ {
+ $auth = Auth::getInstance();
+ $authChain = $auth->getAuthChain();
+ $authChain->setSkipExternalBackends(true);
+ $user = new User($this->username);
+ if (! $user->hasDomain()) {
+ $user->setDomain(Config::app()->get('authentication', 'default_domain'));
+ }
+
+ $authenticated = $authChain->authenticate(
+ $user,
+ $this->aesCrypt->decrypt($this->encryptedPassword)
+ );
+
+ if ($authenticated) {
+ $auth->setAuthenticated($user);
+ }
+
+ return $authenticated;
+ }
+
+ /**
+ * Persist the remember me information into the database
+ *
+ * To remove any previous stored information, set the iv
+ *
+ * @param string|null $iv To remove a specific iv record from the database
+ *
+ * @return $this
+ */
+ public function persist($iv = null)
+ {
+ if ($iv) {
+ $this->remove(bin2hex($iv));
+ }
+
+ $this->getDb()->insert(static::TABLE, [
+ 'username' => $this->username,
+ 'passphrase' => bin2hex($this->aesCrypt->getKey()),
+ 'random_iv' => bin2hex($this->aesCrypt->getIV()),
+ 'http_user_agent' => (new UserAgent)->getAgent(),
+ 'expires_at' => date('Y-m-d H:i:s', $this->getExpiresAt()),
+ 'ctime' => new Expression('NOW()'),
+ 'mtime' => new Expression('NOW()')
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Remove remember me information from the database on the basis of iv
+ *
+ * @param string $iv
+ *
+ * @return $this
+ */
+ public function remove($iv)
+ {
+ $this->getDb()->delete(static::TABLE, [
+ 'random_iv = ?' => $iv
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Create renewed remember me cookie
+ *
+ * @return static New remember me cookie which has to be sent to the client
+ */
+ public function renew()
+ {
+ return static::fromCredentials(
+ $this->username,
+ $this->aesCrypt->decrypt($this->encryptedPassword)
+ );
+ }
+
+ /**
+ * Get all users using remember me cookie
+ *
+ * @return array Array of users
+ */
+ public static function getAllUser()
+ {
+ $rememberMe = new static();
+ if (! $rememberMe->hasDb()) {
+ return [];
+ }
+
+ $select = (new Select())
+ ->from(static::TABLE)
+ ->columns('username')
+ ->groupBy('username');
+
+ return $rememberMe->getDb()->select($select)->fetchAll();
+ }
+
+ /**
+ * Get all remember me entries from the database of the given user.
+ *
+ * @param $username
+ *
+ * @return array Array of database entries
+ */
+ public static function getAllByUsername($username)
+ {
+ $rememberMe = new static();
+ if (! $rememberMe->hasDb()) {
+ return [];
+ }
+
+ $select = (new Select())
+ ->from(static::TABLE)
+ ->columns(['http_user_agent', 'random_iv'])
+ ->where(['username = ?' => $username]);
+
+ return $rememberMe->getDb()->select($select)->fetchAll();
+ }
+
+ /**
+ * Get the AesCrypt instance
+ *
+ * @return AesCrypt
+ */
+ public function getAesCrypt()
+ {
+ return $this->aesCrypt;
+ }
+}
diff --git a/library/Icinga/Web/RememberMeUserDevicesList.php b/library/Icinga/Web/RememberMeUserDevicesList.php
new file mode 100644
index 0000000..66609de
--- /dev/null
+++ b/library/Icinga/Web/RememberMeUserDevicesList.php
@@ -0,0 +1,144 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Url as iplWebUrl; //alias is needed for php5.6
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+class RememberMeUserDevicesList extends BaseHtmlElement
+{
+ protected $tag = 'table';
+
+ protected $defaultAttributes = [
+ 'class' => 'common-table',
+ 'data-base-target' => '_self'
+ ];
+
+ /**
+ * @var array
+ */
+ protected $devicesList;
+
+ /**
+ * @var string
+ */
+ protected $username;
+
+ /**
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * @return string
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * @param string $url
+ *
+ * @return $this
+ */
+ public function setUrl($url)
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getUsername()
+ {
+ return $this->username;
+ }
+
+ /**
+ * @param string $username
+ *
+ * @return $this
+ */
+ public function setUsername($username)
+ {
+ $this->username = $username;
+
+ return $this;
+ }
+
+ /**
+ * @return array List of devices. Each device contains user agent and fingerprint string
+ */
+ public function getDevicesList()
+ {
+ return $this->devicesList;
+ }
+
+ /**
+ * @param $devicesList
+ *
+ * @return $this
+ */
+ public function setDevicesList($devicesList)
+ {
+ $this->devicesList = $devicesList;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $thead = Html::tag('thead');
+ $theadRow = Html::tag('tr')
+ ->add(Html::tag(
+ 'th',
+ sprintf(t('List of devices and browsers %s is currently logged in:'), $this->getUsername())
+ ));
+
+ $thead->add($theadRow);
+
+ $head = Html::tag('tr')
+ ->add(Html::tag('th', t('OS')))
+ ->add(Html::tag('th', t('Browser')))
+ ->add(Html::tag('th', t('Fingerprint')));
+
+ $thead->add($head);
+ $tbody = Html::tag('tbody');
+
+ if (empty($this->getDevicesList())) {
+ $tbody->add(Html::tag('td', t('No device found')));
+ } else {
+ foreach ($this->getDevicesList() as $device) {
+ $agent = new UserAgent($device);
+ $element = Html::tag('tr')
+ ->add(Html::tag('td', $agent->getOs()))
+ ->add(Html::tag('td', $agent->getBrowser()))
+ ->add(Html::tag('td', $device->random_iv));
+
+ $link = (new Link(
+ new Icon('trash'),
+ iplWebUrl::fromPath($this->getUrl())
+ ->addParams(
+ [
+ 'name' => $this->getUsername(),
+ 'fingerprint' => $device->random_iv,
+ ]
+ )
+ ));
+
+ $element->add(Html::tag('td', $link));
+ $tbody->add($element);
+ }
+ }
+
+ $this->add($thead);
+ $this->add($tbody);
+ }
+}
diff --git a/library/Icinga/Web/RememberMeUserList.php b/library/Icinga/Web/RememberMeUserList.php
new file mode 100644
index 0000000..bb95dc9
--- /dev/null
+++ b/library/Icinga/Web/RememberMeUserList.php
@@ -0,0 +1,106 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Url as iplWebUrl; //alias is needed for php5.6
+use ipl\Web\Widget\Link;
+
+/**
+ * Class RememberMeUserList
+ *
+ * @package Icinga\Web
+ */
+class RememberMeUserList extends BaseHtmlElement
+{
+ protected $tag = 'table';
+
+ protected $defaultAttributes = [
+ 'class' => 'common-table table-row-selectable',
+ 'data-base-target' => '_next',
+ ];
+
+ /**
+ * @var array
+ */
+ protected $users;
+
+ /**
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * @return string
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * @param string $url
+ *
+ * @return $this
+ */
+ public function setUrl($url)
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getUsers()
+ {
+ return $this->users;
+ }
+
+ /**
+ * @param array $users
+ *
+ * @return $this
+ */
+ public function setUsers($users)
+ {
+ $this->users = $users;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $thead = Html::tag('thead');
+ $theadRow = Html::tag('tr')
+ ->add(Html::tag(
+ 'th',
+ t('List of users who stay logged in')
+ ));
+
+ $thead->add($theadRow);
+ $tbody = Html::tag('tbody');
+
+ if (empty($this->getUsers())) {
+ $tbody->add(Html::tag('td', t('No user found')));
+ } else {
+ foreach ($this->getUsers() as $user) {
+ $element = Html::tag('tr');
+ $link = new Link(
+ $user->username,
+ iplWebUrl::fromPath($this->getUrl())->addParams(['name' => $user->username]),
+ ['title' => sprintf(t('Device list of %s'), $user->username)]
+ );
+
+ $element->add(Html::tag('td', $link));
+ $tbody->add($element);
+ }
+ }
+
+ $this->add($thead);
+ $this->add($tbody);
+ }
+}
diff --git a/library/Icinga/Web/Request.php b/library/Icinga/Web/Request.php
new file mode 100644
index 0000000..064ce63
--- /dev/null
+++ b/library/Icinga/Web/Request.php
@@ -0,0 +1,142 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Util\Json;
+use Zend_Controller_Request_Http;
+use Icinga\Application\Icinga;
+use Icinga\User;
+
+/**
+ * A request
+ */
+class Request extends Zend_Controller_Request_Http
+{
+ /**
+ * Response
+ *
+ * @var Response
+ */
+ protected $response;
+
+ /**
+ * Unique identifier
+ *
+ * @var string
+ */
+ protected $uniqueId;
+
+ /**
+ * Request URL
+ *
+ * @var Url
+ */
+ protected $url;
+
+ /**
+ * User if authenticated
+ *
+ * @var User|null
+ */
+ protected $user;
+
+ /**
+ * Get the response
+ *
+ * @return Response
+ */
+ public function getResponse()
+ {
+ if ($this->response === null) {
+ $this->response = Icinga::app()->getResponse();
+ }
+
+ return $this->response;
+ }
+
+ /**
+ * Get the request URL
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ if ($this->url === null) {
+ $this->url = Url::fromRequest($this);
+ }
+ return $this->url;
+ }
+
+ /**
+ * Get the user if authenticated
+ *
+ * @return User|null
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Set the authenticated user
+ *
+ * @param User $user
+ *
+ * @return $this
+ */
+ public function setUser(User $user)
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+ /**
+ * Get whether the request seems to be an API request
+ *
+ * @return bool
+ */
+ public function isApiRequest()
+ {
+ return $this->getHeader('Accept') === 'application/json';
+ }
+
+ /**
+ * Makes an ID unique to this request, to prevent id collisions in different containers
+ *
+ * Call this whenever an ID might show up multiple times in different containers. This function is useful
+ * for ensuring unique ids on sites, even if we combine the HTML of different requests into one site,
+ * while still being able to reference elements uniquely in the same request.
+ *
+ * @param string $id
+ *
+ * @return string The id suffixed w/ an identifier unique to this request
+ */
+ public function protectId($id)
+ {
+ return $id . '-' . Window::getInstance()->getContainerId();
+ }
+
+ public function getPost($key = null, $default = null)
+ {
+ if ($key === null && $this->extractMediaType($this->getHeader('Content-Type')) === 'application/json') {
+ return Json::decode(file_get_contents('php://input'), true);
+ }
+
+ return parent::getPost($key, $default);
+ }
+
+ /**
+ * Extract and return the media type from the given header value
+ *
+ * @param string $headerValue
+ *
+ * @return string
+ */
+ protected function extractMediaType($headerValue)
+ {
+ // Pretty basic and does not care about parameters
+ $parts = explode(';', $headerValue, 2);
+ return strtolower(trim($parts[0]));
+ }
+}
diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php
new file mode 100644
index 0000000..555d3fa
--- /dev/null
+++ b/library/Icinga/Web/Response.php
@@ -0,0 +1,460 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Config;
+use Icinga\Util\Csp;
+use Zend_Controller_Response_Http;
+use Icinga\Application\Icinga;
+use Icinga\Web\Response\JsonResponse;
+
+/**
+ * A HTTP response
+ */
+class Response extends Zend_Controller_Response_Http
+{
+ /**
+ * The default content type being used for responses
+ *
+ * @var string
+ */
+ const DEFAULT_CONTENT_TYPE = 'text/html; charset=UTF-8';
+
+ /**
+ * Auto-refresh interval
+ *
+ * @var int
+ */
+ protected $autoRefreshInterval;
+
+ /**
+ * Set of cookies which are to be sent to the client
+ *
+ * @var CookieSet
+ */
+ protected $cookies;
+
+ /**
+ * Redirect URL
+ *
+ * @var Url|null
+ */
+ protected $redirectUrl;
+
+ /**
+ * Request
+ *
+ * @var Request
+ */
+ protected $request;
+
+ /**
+ * Whether to instruct the client to reload the window
+ *
+ * @var bool
+ */
+ protected $reloadWindow;
+
+ /**
+ * Whether to instruct client side script code to reload CSS
+ *
+ * @var bool
+ */
+ protected $reloadCss;
+
+ /**
+ * Whether to send the rerender layout header on XHR
+ *
+ * @var bool
+ */
+ protected $rerenderLayout = false;
+
+ /**
+ * Whether to send the current window ID to the client
+ *
+ * @var bool
+ */
+ protected $overrideWindowId = false;
+
+ /**
+ * Get the auto-refresh interval
+ *
+ * @return int
+ */
+ public function getAutoRefreshInterval()
+ {
+ return $this->autoRefreshInterval;
+ }
+
+ /**
+ * Set the auto-refresh interval
+ *
+ * @param int $autoRefreshInterval
+ *
+ * @return $this
+ */
+ public function setAutoRefreshInterval($autoRefreshInterval)
+ {
+ $this->autoRefreshInterval = $autoRefreshInterval;
+ return $this;
+ }
+
+ /**
+ * Get the set of cookies which are to be sent to the client
+ *
+ * @return CookieSet
+ */
+ public function getCookies()
+ {
+ if ($this->cookies === null) {
+ $this->cookies = new CookieSet();
+ }
+ return $this->cookies;
+ }
+
+ /**
+ * Get the cookie with the given name from the set of cookies which are to be sent to the client
+ *
+ * @param string $name The name of the cookie
+ *
+ * @return Cookie|null The cookie with the given name or null if the cookie does not exist
+ */
+ public function getCookie($name)
+ {
+ return $this->getCookies()->get($name);
+ }
+
+ /**
+ * Set the given cookie for sending it to the client
+ *
+ * @param Cookie $cookie The cookie to send to the client
+ *
+ * @return $this
+ */
+ public function setCookie(Cookie $cookie)
+ {
+ $this->getCookies()->add($cookie);
+ return $this;
+ }
+
+ /**
+ * Get the redirect URL
+ *
+ * @return Url|null
+ */
+ protected function getRedirectUrl()
+ {
+ return $this->redirectUrl;
+ }
+
+ /**
+ * Set the redirect URL
+ *
+ * Unlike {@link setRedirect()} this method only sets a redirect URL on the response for later usage.
+ * {@link prepare()} will take care of the correct redirect handling and HTTP headers on XHR and "normal" browser
+ * requests.
+ *
+ * @param string|Url $redirectUrl
+ *
+ * @return $this
+ */
+ protected function setRedirectUrl($redirectUrl)
+ {
+ if (! $redirectUrl instanceof Url) {
+ $redirectUrl = Url::fromPath((string) $redirectUrl);
+ }
+ $redirectUrl->getParams()->setSeparator('&');
+ $this->redirectUrl = $redirectUrl;
+ return $this;
+ }
+
+ /**
+ * Get an array of all header values for the given name
+ *
+ * @param string $name The name of the header
+ * @param bool $lastOnly If this is true, the last value will be returned as a string
+ *
+ * @return null|array|string
+ */
+ public function getHeader($name, $lastOnly = false)
+ {
+ $result = ($lastOnly ? null : array());
+ $headers = $this->getHeaders();
+ foreach ($headers as $header) {
+ if ($header['name'] === $name) {
+ if ($lastOnly) {
+ $result = $header['value'];
+ } else {
+ $result[] = $header['value'];
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the request
+ *
+ * @return Request
+ */
+ public function getRequest()
+ {
+ if ($this->request === null) {
+ $this->request = Icinga::app()->getRequest();
+ }
+ return $this->request;
+ }
+
+ /**
+ * Get whether to instruct the client to reload the window
+ *
+ * @return bool
+ */
+ public function isWindowReloaded()
+ {
+ return $this->reloadWindow;
+ }
+
+ /**
+ * Set whether to instruct the client to reload the window
+ *
+ * @param bool $reloadWindow
+ *
+ * @return $this
+ */
+ public function setReloadWindow($reloadWindow)
+ {
+ $this->reloadWindow = $reloadWindow;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to instruct client side script code to reload CSS
+ *
+ * @return bool
+ */
+ public function isReloadCss()
+ {
+ return $this->reloadCss;
+ }
+
+ /**
+ * Set whether to instruct client side script code to reload CSS
+ *
+ * @param bool $reloadCss
+ *
+ * @return $this
+ */
+ public function setReloadCss($reloadCss)
+ {
+ $this->reloadCss = $reloadCss;
+ return $this;
+ }
+
+ /**
+ * Get whether to send the rerender layout header on XHR
+ *
+ * @return bool
+ */
+ public function getRerenderLayout()
+ {
+ return $this->rerenderLayout;
+ }
+
+ /**
+ * Get whether to send the rerender layout header on XHR
+ *
+ * @param bool $rerenderLayout
+ *
+ * @return $this
+ */
+ public function setRerenderLayout($rerenderLayout = true)
+ {
+ $this->rerenderLayout = (bool) $rerenderLayout;
+ return $this;
+ }
+
+ /**
+ * Get whether to send the current window ID to the client
+ *
+ * @return bool
+ */
+ public function getOverrideWindowId()
+ {
+ return $this->overrideWindowId;
+ }
+
+ /**
+ * Set whether to send the current window ID to the client
+ *
+ * @param bool $overrideWindowId
+ *
+ * @return $this
+ */
+ public function setOverrideWindowId($overrideWindowId = true)
+ {
+ $this->overrideWindowId = $overrideWindowId;
+ return $this;
+ }
+
+ /**
+ * Entry point for HTTP responses in JSON format
+ *
+ * @return JsonResponse
+ */
+ public function json()
+ {
+ $response = new JsonResponse();
+ $response->copyMetaDataFrom($this);
+ return $response;
+ }
+
+ /**
+ * Prepare the request before sending
+ */
+ protected function prepare()
+ {
+ $request = $this->getRequest();
+ $redirectUrl = $this->getRedirectUrl();
+ if ($request->isXmlHttpRequest()) {
+ if ($redirectUrl !== null) {
+ if ($request->isGet() && Icinga::app()->getViewRenderer()->view->compact) {
+ if ($redirectUrl->getParam('redirect') !== '__SELF__') {
+ $redirectUrl->getParams()->set('showCompact', true);
+ }
+ }
+
+ $encodedRedirectUrl = rawurlencode($redirectUrl->getAbsoluteUrl());
+
+ // TODO: Compatibility only. Remove once v2.14 is out.
+ $targetId = $request->getHeader('X-Icinga-Container');
+ $redirectTargetId = $this->getHeader('X-Icinga-Container', true) ?? $targetId;
+ if ($request->isPost()
+ && ! $this->getRerenderLayout()
+ && $targetId === 'col2'
+ && $redirectTargetId === $targetId
+ && $request->getHeader('X-Icinga-Col2-State')
+ ) {
+ $col1State = Url::fromPath($request->getHeader('X-Icinga-Col1-State'));
+ $col2State = Url::fromPath($request->getHeader('X-Icinga-Col2-State'));
+ if ($col2State->getPath() !== $redirectUrl->getPath()
+ && $col1State->getPath() === $redirectUrl->getPath()
+ ) {
+ $encodedRedirectUrl = '__CLOSE__';
+ }
+ }
+
+ $this->setHeader('X-Icinga-Redirect', $encodedRedirectUrl, true);
+ if ($this->getRerenderLayout()) {
+ $this->setHeader('X-Icinga-Rerender-Layout', 'yes', true);
+ }
+ }
+ if ($this->getOverrideWindowId()) {
+ $this->setHeader('X-Icinga-WindowId', Window::getInstance()->getId(), true);
+ }
+ if ($this->getRerenderLayout()) {
+ $this->setHeader('X-Icinga-Container', 'layout', true);
+ }
+ if ($this->isWindowReloaded()) {
+ $this->setHeader('X-Icinga-Reload-Window', 'yes', true);
+ }
+ if ($this->isReloadCss()) {
+ $this->setHeader('X-Icinga-Reload-Css', 'now', true);
+ }
+ if (($autoRefreshInterval = $this->getAutoRefreshInterval()) !== null) {
+ $this->setHeader('X-Icinga-Refresh', $autoRefreshInterval, true);
+ }
+
+ $notifications = Notification::getInstance();
+ if ($notifications->hasMessages()) {
+ $notificationList = array();
+ foreach ($notifications->popMessages() as $m) {
+ $notificationList[] = rawurlencode($m->type . ' ' . $m->message);
+ }
+ $this->setHeader('X-Icinga-Notification', implode('&', $notificationList), true);
+ }
+ } else {
+ if ($redirectUrl !== null) {
+ $this->setRedirect($redirectUrl->getAbsoluteUrl());
+ }
+
+ if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) {
+ Csp::addHeader($this);
+ }
+ }
+
+ if (! $this->getHeader('Content-Type', true)) {
+ $this->setHeader('Content-Type', static::DEFAULT_CONTENT_TYPE);
+ }
+ }
+
+ /**
+ * Redirect to the given URL and exit immediately
+ *
+ * @param string|Url $url
+ *
+ * @return never
+ */
+ public function redirectAndExit($url)
+ {
+ $this->setRedirectUrl($url);
+
+ $session = Session::getSession();
+ if ($session->hasChanged()) {
+ $session->write();
+ }
+
+ $this->sendHeaders();
+ exit;
+ }
+
+ /**
+ * Send the cookies to the client
+ */
+ public function sendCookies()
+ {
+ foreach ($this->getCookies() as $cookie) {
+ /** @var Cookie $cookie */
+ setcookie(
+ $cookie->getName(),
+ $cookie->getValue() ?? '',
+ $cookie->getExpire() ?? 0,
+ $cookie->getPath(),
+ $cookie->getDomain() ?? '',
+ $cookie->isSecure(),
+ $cookie->isHttpOnly() ?? true
+ );
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function sendHeaders()
+ {
+ $this->prepare();
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->sendCookies();
+ }
+ return parent::sendHeaders();
+ }
+
+ /**
+ * Copies non-body-related response data from $response
+ *
+ * @param Response $response
+ *
+ * @return $this
+ */
+ protected function copyMetaDataFrom(self $response)
+ {
+ $this->_headers = $response->_headers;
+ $this->_headersRaw = $response->_headersRaw;
+ $this->_httpResponseCode = $response->_httpResponseCode;
+ $this->headersSentThrowsException = $response->headersSentThrowsException;
+ return $this;
+ }
+}
diff --git a/library/Icinga/Web/Response/JsonResponse.php b/library/Icinga/Web/Response/JsonResponse.php
new file mode 100644
index 0000000..025e88d
--- /dev/null
+++ b/library/Icinga/Web/Response/JsonResponse.php
@@ -0,0 +1,241 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Response;
+
+use Icinga\Util\Json;
+use Zend_Controller_Action_HelperBroker;
+use Icinga\Web\Response;
+
+/**
+ * HTTP response in JSON format
+ */
+class JsonResponse extends Response
+{
+ /**
+ * {@inheritdoc}
+ */
+ const DEFAULT_CONTENT_TYPE = 'application/json';
+
+ /**
+ * Status identifier for failed API calls due to an error on the server
+ *
+ * @var string
+ */
+ const STATUS_ERROR = 'error';
+
+ /**
+ * Status identifier for rejected API calls most due to invalid data or call conditions
+ *
+ * @var string
+ */
+ const STATUS_FAIL = 'fail';
+
+ /**
+ * Status identifier for successful API requests
+ *
+ * @var string
+ */
+ const STATUS_SUCCESS = 'success';
+
+ /**
+ * JSON encoding options
+ *
+ * @var int
+ */
+ protected $encodingOptions = 0;
+
+ /**
+ * Whether to automatically sanitize invalid UTF-8 (if any)
+ *
+ * @var bool
+ */
+ protected $autoSanitize = false;
+
+ /**
+ * Error message if the API call failed due to a server error
+ *
+ * @var string|null
+ */
+ protected $errorMessage;
+
+ /**
+ * Fail data for rejected API calls
+ *
+ * @var array|null
+ */
+ protected $failData;
+
+ /**
+ * API request status
+ *
+ * @var string
+ */
+ protected $status;
+
+ /**
+ * Success data for successful API requests
+ *
+ * @var array|null
+ */
+ protected $successData;
+
+ /**
+ * Get the JSON encoding options
+ *
+ * @return int
+ */
+ public function getEncodingOptions()
+ {
+ return $this->encodingOptions;
+ }
+
+ /**
+ * Set the JSON encoding options
+ *
+ * @param int $encodingOptions
+ *
+ * @return $this
+ */
+ public function setEncodingOptions($encodingOptions)
+ {
+ $this->encodingOptions = (int) $encodingOptions;
+ return $this;
+ }
+
+ /**
+ * Get whether to automatically sanitize invalid UTF-8 (if any)
+ *
+ * @return bool
+ */
+ public function getAutoSanitize()
+ {
+ return $this->autoSanitize;
+ }
+
+ /**
+ * Set whether to automatically sanitize invalid UTF-8 (if any)
+ *
+ * @param bool $autoSanitize
+ *
+ * @return $this
+ */
+ public function setAutoSanitize($autoSanitize = true)
+ {
+ $this->autoSanitize = $autoSanitize;
+
+ return $this;
+ }
+
+ /**
+ * Get the error message if the API call failed due to a server error
+ *
+ * @return string|null
+ */
+ public function getErrorMessage()
+ {
+ return $this->errorMessage;
+ }
+
+ /**
+ * Set the error message if the API call failed due to a server error
+ *
+ * @param string $errorMessage
+ *
+ * @return $this
+ */
+ public function setErrorMessage($errorMessage)
+ {
+ $this->errorMessage = (string) $errorMessage;
+ $this->status = static::STATUS_ERROR;
+ return $this;
+ }
+
+ /**
+ * Get the fail data for rejected API calls
+ *
+ * @return array|null
+ */
+ public function getFailData()
+ {
+ return (! is_array($this->failData) || empty($this->failData)) ? null : $this->failData;
+ }
+
+ /**
+ * Set the fail data for rejected API calls
+ *
+ * @param array $failData
+ *
+ * @return $this
+ */
+ public function setFailData(array $failData)
+ {
+ $this->failData = $failData;
+ $this->status = static::STATUS_FAIL;
+ return $this;
+ }
+
+ /**
+ * Get the data for successful API requests
+ *
+ * @return array|null
+ */
+ public function getSuccessData()
+ {
+ return (! is_array($this->successData) || empty($this->successData)) ? null : $this->successData;
+ }
+
+ /**
+ * Set the data for successful API requests
+ *
+ * @param array $successData
+ *
+ * @return $this
+ */
+ public function setSuccessData(array $successData = null)
+ {
+ $this->successData = $successData;
+ $this->status = static::STATUS_SUCCESS;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function outputBody()
+ {
+ $body = array(
+ 'status' => $this->status
+ );
+ switch ($this->status) {
+ /** @noinspection PhpMissingBreakStatementInspection */
+ case static::STATUS_ERROR:
+ $body['message'] = $this->getErrorMessage();
+ // Fallthrough
+ case static::STATUS_FAIL:
+ $failData = $this->getFailData();
+ if ($failData !== null || $this->status === static::STATUS_FAIL) {
+ $body['data'] = $failData;
+ }
+ break;
+ case static::STATUS_SUCCESS:
+ $body['data'] = $this->getSuccessData();
+ break;
+ }
+ echo $this->getAutoSanitize()
+ ? Json::sanitize($body, $this->getEncodingOptions())
+ : Json::encode($body, $this->getEncodingOptions());
+ }
+
+ /**
+ * Send the response, including all headers, excluding a rendered view.
+ *
+ * @return never
+ */
+ public function sendResponse()
+ {
+ Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer')->setNoRender(true);
+ parent::sendResponse();
+ exit;
+ }
+}
diff --git a/library/Icinga/Web/Session.php b/library/Icinga/Web/Session.php
new file mode 100644
index 0000000..40df89f
--- /dev/null
+++ b/library/Icinga/Web/Session.php
@@ -0,0 +1,54 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Web\Session\PhpSession;
+use Icinga\Web\Session\Session as BaseSession;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Session container
+ */
+class Session
+{
+ /**
+ * The current session
+ *
+ * @var BaseSession $session
+ */
+ private static $session;
+
+ /**
+ * Create the session
+ *
+ * @param BaseSession $session
+ *
+ * @return BaseSession
+ */
+ public static function create(BaseSession $session = null)
+ {
+ if ($session === null) {
+ self::$session = PhpSession::create();
+ } else {
+ self::$session = $session;
+ }
+
+ return self::$session;
+ }
+
+ /**
+ * Return the current session
+ *
+ * @return BaseSession
+ * @throws ProgrammingError
+ */
+ public static function getSession()
+ {
+ if (self::$session === null) {
+ self::create();
+ }
+
+ return self::$session;
+ }
+}
diff --git a/library/Icinga/Web/Session/Php72Session.php b/library/Icinga/Web/Session/Php72Session.php
new file mode 100644
index 0000000..e6a6b19
--- /dev/null
+++ b/library/Icinga/Web/Session/Php72Session.php
@@ -0,0 +1,37 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Session;
+
+use Icinga\Application\Logger;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Web\Cookie;
+
+/**
+ * Session implementation in PHP
+ */
+class Php72Session extends PhpSession
+{
+ /**
+ * Open a PHP session
+ */
+ protected function open()
+ {
+ session_name($this->sessionName);
+
+ $cookie = new Cookie('bogus');
+ session_set_cookie_params(
+ 0,
+ $cookie->getPath(),
+ $cookie->getDomain(),
+ $cookie->isSecure(),
+ true
+ );
+
+ session_start(array(
+ 'use_cookies' => true,
+ 'use_only_cookies' => true,
+ 'use_trans_sid' => false
+ ));
+ }
+}
diff --git a/library/Icinga/Web/Session/PhpSession.php b/library/Icinga/Web/Session/PhpSession.php
new file mode 100644
index 0000000..36dd84e
--- /dev/null
+++ b/library/Icinga/Web/Session/PhpSession.php
@@ -0,0 +1,256 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Session;
+
+use Icinga\Application\Logger;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Web\Cookie;
+
+/**
+ * Session implementation in PHP
+ */
+class PhpSession extends Session
+{
+ /**
+ * The namespace prefix
+ *
+ * Used to differentiate between standard session keys and namespace identifiers
+ */
+ const NAMESPACE_PREFIX = 'ns.';
+
+ /**
+ * Whether the session has already been closed
+ *
+ * @var bool
+ */
+ protected $hasBeenTouched = false;
+
+ /**
+ * Name of the session
+ *
+ * @var string
+ */
+ protected $sessionName = 'Icingaweb2';
+
+ /**
+ * Create a new PHPSession object using the provided options (if any)
+ *
+ * @param array $options An optional array of ini options to set
+ *
+ * @return static
+ *
+ * @throws ConfigurationError
+ * @see http://php.net/manual/en/session.configuration.php
+ */
+ public static function create(array $options = null)
+ {
+ return version_compare(PHP_VERSION, '7.2.0') < 0 ? new self($options) : new Php72Session($options);
+ }
+
+ /**
+ * Create a new PHPSession object using the provided options (if any)
+ *
+ * @param array $options An optional array of ini options to set
+ *
+ * @throws ConfigurationError
+ * @see http://php.net/manual/en/session.configuration.php
+ */
+ public function __construct(array $options = null)
+ {
+ $defaultCookieOptions = array(
+ 'use_trans_sid' => false,
+ 'use_cookies' => true,
+ 'cookie_httponly' => true,
+ 'use_only_cookies' => true
+ );
+
+ if (version_compare(PHP_VERSION, '7.1.0') < 0) {
+ $defaultCookieOptions['hash_function'] = true;
+ $defaultCookieOptions['hash_bits_per_character'] = 5;
+ } else {
+ $defaultCookieOptions['sid_bits_per_character'] = 5;
+ }
+
+ if ($options !== null) {
+ $options = array_merge($defaultCookieOptions, $options);
+ } else {
+ $options = $defaultCookieOptions;
+ }
+
+ if (array_key_exists('test_session_name', $options)) {
+ $this->sessionName = $options['test_session_name'];
+ unset($options['test_session_name']);
+ }
+
+ foreach ($options as $sessionVar => $value) {
+ if (ini_set("session." . $sessionVar, $value) === false) {
+ Logger::warning(
+ 'Could not set php.ini setting %s = %s. This might affect your sessions behaviour.',
+ $sessionVar,
+ $value
+ );
+ }
+ }
+
+ $sessionSavePath = session_save_path() ?: sys_get_temp_dir();
+ if (session_module_name() === 'files' && !is_writable($sessionSavePath)) {
+ throw new ConfigurationError("Can't save session, path '$sessionSavePath' is not writable.");
+ }
+
+ if ($this->exists()) {
+ // We do not want to start a new session here if there is not any
+ $this->read();
+ }
+ }
+
+ /**
+ * Open a PHP session
+ */
+ protected function open()
+ {
+ session_name($this->sessionName);
+
+ if ($this->hasBeenTouched) {
+ $cacheLimiter = ini_get('session.cache_limiter');
+ ini_set('session.use_cookies', false);
+ ini_set('session.use_only_cookies', false);
+ ini_set('session.cache_limiter', null);
+ }
+
+ $cookie = new Cookie('bogus');
+ session_set_cookie_params(
+ 0,
+ $cookie->getPath(),
+ $cookie->getDomain(),
+ $cookie->isSecure(),
+ true
+ );
+
+ session_start();
+
+ if ($this->hasBeenTouched) {
+ ini_set('session.use_cookies', true);
+ ini_set('session.use_only_cookies', true);
+ /** @noinspection PhpUndefinedVariableInspection */
+ ini_set('session.cache_limiter', $cacheLimiter);
+ }
+ }
+
+ /**
+ * Read all values written to the underling session and make them accessible.
+ */
+ public function read()
+ {
+ $this->clear();
+ $this->open();
+
+ foreach ($_SESSION as $key => $value) {
+ if (strpos($key, self::NAMESPACE_PREFIX) === 0) {
+ $namespace = new SessionNamespace();
+ $namespace->setAll($value);
+ $this->namespaces[substr($key, strlen(self::NAMESPACE_PREFIX))] = $namespace;
+ } else {
+ $this->set($key, $value);
+ }
+ }
+
+ session_write_close();
+ $this->hasBeenTouched = true;
+ }
+
+ /**
+ * Write all values of this session object to the underlying session implementation
+ */
+ public function write()
+ {
+ $this->open();
+
+ foreach ($this->removed as $key) {
+ unset($_SESSION[$key]);
+ }
+ foreach ($this->values as $key => $value) {
+ $_SESSION[$key] = $value;
+ }
+ foreach ($this->removedNamespaces as $identifier) {
+ unset($_SESSION[self::NAMESPACE_PREFIX . $identifier]);
+ }
+ foreach ($this->namespaces as $identifier => $namespace) {
+ $_SESSION[self::NAMESPACE_PREFIX . $identifier] = $namespace->getAll();
+ }
+
+ session_write_close();
+ $this->hasBeenTouched = true;
+ }
+
+ /**
+ * Delete the current session, causing all session information to be lost
+ */
+ public function purge()
+ {
+ $this->open();
+ $_SESSION = array();
+ $this->clear();
+ session_destroy();
+ $this->clearCookies();
+ session_write_close();
+ $this->hasBeenTouched = true;
+ }
+
+ /**
+ * Remove session cookies
+ */
+ protected function clearCookies()
+ {
+ if (ini_get('session.use_cookies')) {
+ Logger::debug('Clear session cookie');
+ $params = session_get_cookie_params();
+ setcookie(
+ session_name(),
+ '',
+ time() - 42000,
+ $params['path'],
+ $params['domain'],
+ $params['secure'],
+ $params['httponly']
+ );
+ }
+ }
+
+ /**
+ * @see Session::getId()
+ */
+ public function getId()
+ {
+ if (($id = session_id()) === '') {
+ // Make sure we actually get a id
+ $this->open();
+ session_write_close();
+ $this->hasBeenTouched = true;
+ $id = session_id();
+ }
+
+ return $id;
+ }
+
+ /**
+ * Assign a new sessionId to the currently active session
+ */
+ public function refreshId()
+ {
+ $this->open();
+ if ($this->exists()) {
+ session_regenerate_id();
+ }
+ session_write_close();
+ $this->hasBeenTouched = true;
+ }
+
+ /**
+ * @see Session::exists()
+ */
+ public function exists()
+ {
+ return isset($_COOKIE[$this->sessionName]);
+ }
+}
diff --git a/library/Icinga/Web/Session/Session.php b/library/Icinga/Web/Session/Session.php
new file mode 100644
index 0000000..e73e9b4
--- /dev/null
+++ b/library/Icinga/Web/Session/Session.php
@@ -0,0 +1,126 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Session;
+
+use Icinga\Exception\NotImplementedError;
+
+/**
+ * Base class for handling sessions
+ */
+abstract class Session extends SessionNamespace
+{
+ /**
+ * Container for session namespaces
+ *
+ * @var array
+ */
+ protected $namespaces = array();
+
+ /**
+ * The identifiers of all namespaces removed from this session
+ *
+ * @var array
+ */
+ protected $removedNamespaces = array();
+
+ /**
+ * Read all values from the underlying session implementation
+ */
+ abstract public function read();
+
+ /**
+ * Persists changes to the underlying session implementation
+ */
+ public function write()
+ {
+ throw new NotImplementedError('You are required to implement write() in your session implementation');
+ }
+
+ /**
+ * Return whether a session exists
+ *
+ * @return bool
+ */
+ abstract public function exists();
+
+ /**
+ * Purge session
+ */
+ abstract public function purge();
+
+ /**
+ * Assign a new session id to this session.
+ */
+ abstract public function refreshId();
+
+ /**
+ * Return the id of this session
+ *
+ * @return string
+ */
+ abstract public function getId();
+
+ /**
+ * Get or create a new session namespace
+ *
+ * @param string $identifier The namespace's identifier
+ *
+ * @return SessionNamespace
+ */
+ public function getNamespace($identifier)
+ {
+ if (!isset($this->namespaces[$identifier])) {
+ if (in_array($identifier, $this->removedNamespaces, true)) {
+ unset($this->removedNamespaces[array_search($identifier, $this->removedNamespaces, true)]);
+ }
+
+ $this->namespaces[$identifier] = new SessionNamespace();
+ }
+
+ return $this->namespaces[$identifier];
+ }
+
+ /**
+ * Return whether the given session namespace exists
+ *
+ * @param string $identifier The namespace's identifier to check
+ *
+ * @return bool
+ */
+ public function hasNamespace($identifier)
+ {
+ return isset($this->namespaces[$identifier]);
+ }
+
+ /**
+ * Remove the given session namespace
+ *
+ * @param string $identifier The identifier of the namespace to remove
+ */
+ public function removeNamespace($identifier)
+ {
+ unset($this->namespaces[$identifier]);
+ $this->removedNamespaces[] = $identifier;
+ }
+
+ /**
+ * Return whether the session has changed
+ *
+ * @return bool
+ */
+ public function hasChanged()
+ {
+ return parent::hasChanged() || false === empty($this->namespaces) || false === empty($this->removedNamespaces);
+ }
+
+ /**
+ * Clear all values and namespaces from the session cache
+ */
+ public function clear()
+ {
+ parent::clear();
+ $this->namespaces = array();
+ $this->removedNamespaces = array();
+ }
+}
diff --git a/library/Icinga/Web/Session/SessionNamespace.php b/library/Icinga/Web/Session/SessionNamespace.php
new file mode 100644
index 0000000..1c9c13f
--- /dev/null
+++ b/library/Icinga/Web/Session/SessionNamespace.php
@@ -0,0 +1,201 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Session;
+
+use Exception;
+use ArrayIterator;
+use Icinga\Exception\IcingaException;
+use IteratorAggregate;
+use Traversable;
+
+/**
+ * Container for session values
+ */
+class SessionNamespace implements IteratorAggregate
+{
+ /**
+ * The actual values stored in this container
+ *
+ * @var array
+ */
+ protected $values = array();
+
+ /**
+ * The names of all values removed from this container
+ *
+ * @var array
+ */
+ protected $removed = array();
+
+ /**
+ * Return an iterator for all values in this namespace
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->getAll());
+ }
+
+ /**
+ * Set a session value by property access
+ *
+ * @param string $key The value's name
+ * @param mixed $value The value
+ */
+ public function __set($key, $value)
+ {
+ $this->set($key, $value);
+ }
+
+ /**
+ * Return a session value by property access
+ *
+ * @param string $key The value's name
+ *
+ * @return mixed The value
+ * @throws Exception When the given value-name is not found
+ */
+ public function __get($key)
+ {
+ if (!array_key_exists($key, $this->values)) {
+ throw new IcingaException(
+ 'Cannot access non-existent session value "%s"',
+ $key
+ );
+ }
+
+ return $this->get($key);
+ }
+
+ /**
+ * Return whether the given session value is set
+ *
+ * @param string $key The value's name
+ * @return bool
+ */
+ public function __isset($key)
+ {
+ return isset($this->values[$key]);
+ }
+
+ /**
+ * Unset the given session value
+ *
+ * @param string $key The value's name
+ */
+ public function __unset($key)
+ {
+ $this->delete($key);
+ }
+
+ /**
+ * Setter for session values
+ *
+ * @param string $key Name of value
+ * @param mixed $value Value to set
+ *
+ * @return $this
+ */
+ public function set($key, $value)
+ {
+ $this->values[$key] = $value;
+
+ if (in_array($key, $this->removed, true)) {
+ unset($this->removed[array_search($key, $this->removed, true)]);
+ }
+
+ return $this;
+ }
+
+ public function setByRef($key, &$value)
+ {
+ $this->values[$key] = & $value;
+
+ if (in_array($key, $this->removed, true)) {
+ unset($this->removed[array_search($key, $this->removed, true)]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Getter for session values
+ *
+ * @param string $key Name of the value to return
+ * @param mixed $default Default value to return
+ *
+ * @return mixed
+ */
+ public function get($key, $default = null)
+ {
+ return isset($this->values[$key]) ? $this->values[$key] : $default;
+ }
+
+ public function & getByRef($key, $default = null)
+ {
+ $value = $default;
+ if (isset($this->values[$key])) {
+ $value = & $this->values[$key];
+ }
+
+ return $value;
+ }
+
+ /**
+ * Delete the given value from the session
+ *
+ * @param string $key The value's name
+ */
+ public function delete($key)
+ {
+ $this->removed[] = $key;
+ unset($this->values[$key]);
+ }
+
+ /**
+ * Getter for all session values
+ *
+ * @return array
+ */
+ public function getAll()
+ {
+ return $this->values;
+ }
+
+ /**
+ * Put an array into the session
+ *
+ * @param array $values Values to set
+ * @param bool $overwrite Overwrite existing values
+ */
+ public function setAll(array $values, $overwrite = false)
+ {
+ foreach ($values as $key => $value) {
+ if ($this->get($key, $value) !== $value && !$overwrite) {
+ continue;
+ }
+ $this->set($key, $value);
+ }
+ }
+
+ /**
+ * Return whether the session namespace has been changed
+ *
+ * @return bool
+ */
+ public function hasChanged()
+ {
+ return false === empty($this->values) || false === empty($this->removed);
+ }
+
+ /**
+ * Clear all values from the session namespace
+ */
+ public function clear()
+ {
+ $this->values = array();
+ $this->removed = array();
+ }
+}
diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php
new file mode 100644
index 0000000..65cbb97
--- /dev/null
+++ b/library/Icinga/Web/StyleSheet.php
@@ -0,0 +1,342 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Exception;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\IcingaException;
+
+/**
+ * Send CSS for Web 2 and all loaded modules to the client
+ */
+class StyleSheet
+{
+ /**
+ * The name of the default theme
+ *
+ * @var string
+ */
+ const DEFAULT_THEME = 'Icinga';
+
+ /**
+ * The name of the default theme mode
+ *
+ * @var string
+ */
+ const DEFAULT_MODE = 'none';
+
+ /**
+ * The themes that are compatible with the default theme
+ *
+ * @var array
+ */
+ const THEME_WHITELIST = [
+ 'colorblind',
+ 'high-contrast',
+ 'Winter'
+ ];
+
+ /**
+ * Sequence that signals that a theme supports light mode
+ *
+ * @var string
+ */
+ const LIGHT_MODE_IDENTIFIER = '@light-mode:';
+
+ /**
+ * Array of core LESS files Web 2 sends to the client
+ *
+ * @var string[]
+ */
+ protected static $lessFiles = [
+ '../application/fonts/fontello-ifont/css/ifont-embedded.css',
+ 'css/vendor/normalize.css',
+ 'css/icinga/base.less',
+ 'css/icinga/badges.less',
+ 'css/icinga/configmenu.less',
+ 'css/icinga/mixins.less',
+ 'css/icinga/grid.less',
+ 'css/icinga/nav.less',
+ 'css/icinga/main.less',
+ 'css/icinga/animation.less',
+ 'css/icinga/layout.less',
+ 'css/icinga/layout-structure.less',
+ 'css/icinga/menu.less',
+ 'css/icinga/tabs.less',
+ 'css/icinga/forms.less',
+ 'css/icinga/setup.less',
+ 'css/icinga/widgets.less',
+ 'css/icinga/login.less',
+ 'css/icinga/about.less',
+ 'css/icinga/controls.less',
+ 'css/icinga/dev.less',
+ 'css/icinga/spinner.less',
+ 'css/icinga/compat.less',
+ 'css/icinga/print.less',
+ 'css/icinga/responsive.less',
+ 'css/icinga/modal.less',
+ 'css/icinga/audit.less',
+ 'css/icinga/health.less',
+ 'css/icinga/php-diff.less',
+ 'css/icinga/pending-migration.less',
+ ];
+
+ /**
+ * Application instance
+ *
+ * @var \Icinga\Application\EmbeddedWeb
+ */
+ protected $app;
+
+ /** @var string[] Pre-compiled CSS files */
+ protected $cssFiles = [];
+
+ /**
+ * Less compiler
+ *
+ * @var LessCompiler
+ */
+ protected $lessCompiler;
+
+ /**
+ * Path to the public directory
+ *
+ * @var string
+ */
+ protected $pubPath;
+
+ /**
+ * Create the StyleSheet
+ */
+ public function __construct()
+ {
+ $app = Icinga::app();
+ $this->app = $app;
+ $this->lessCompiler = new LessCompiler();
+ $this->pubPath = $app->getBaseDir('public');
+ $this->collect();
+ }
+
+ /**
+ * Collect Web 2 and module LESS files and add them to the LESS compiler
+ */
+ protected function collect()
+ {
+ foreach ($this->app->getLibraries() as $library) {
+ foreach ($library->getCssAssets() as $lessFile) {
+ if (substr($lessFile, -4) === '.css') {
+ $this->cssFiles[] = $lessFile;
+ } else {
+ $this->lessCompiler->addLessFile($lessFile);
+ }
+ }
+ }
+
+ foreach (self::$lessFiles as $lessFile) {
+ $this->lessCompiler->addLessFile($this->pubPath . '/' . $lessFile);
+ }
+
+ $mm = $this->app->getModuleManager();
+
+ foreach ($mm->getLoadedModules() as $moduleName => $module) {
+ if ($module->hasCss()) {
+ foreach ($module->getCssFiles() as $lessFilePath) {
+ $this->lessCompiler->addModuleLessFile($moduleName, $lessFilePath);
+ }
+ }
+ }
+
+ $themingConfig = $this->app->getConfig()->getSection('themes');
+ $defaultTheme = $themingConfig->get('default');
+ $theme = null;
+ if ($defaultTheme !== null && $defaultTheme !== self::DEFAULT_THEME) {
+ $theme = $defaultTheme;
+ }
+
+ if (! (bool) $themingConfig->get('disabled', false)) {
+ $auth = Auth::getInstance();
+ if ($auth->isAuthenticated()) {
+ $userTheme = $auth->getUser()->getPreferences()->getValue('icingaweb', 'theme');
+ if ($userTheme !== null) {
+ $theme = $userTheme;
+ }
+ }
+ }
+
+ if ($themePath = self::getThemeFile($theme)) {
+ if ($this->app->isCli() || is_file($themePath) && is_readable($themePath)) {
+ $this->lessCompiler->setTheme($themePath);
+ } else {
+ $themePath = null;
+ Logger::warning(sprintf(
+ 'Theme "%s" set by user "%s" has not been found.',
+ $theme,
+ ($user = Auth::getInstance()->getUser()) !== null ? $user->getUsername() : 'anonymous'
+ ));
+ }
+ }
+
+ if (! $themePath || in_array($theme, self::THEME_WHITELIST, true)) {
+ $this->lessCompiler->addLessFile($this->pubPath . '/css/icinga/login-orbs.less');
+ }
+
+ $mode = 'none';
+ if ($user = Auth::getInstance()->getUser()) {
+ $file = $themePath !== null ? @file_get_contents($themePath) : false;
+ if (! $file || strpos($file, self::LIGHT_MODE_IDENTIFIER) !== false) {
+ $mode = $user->getPreferences()->getValue('icingaweb', 'theme_mode', self::DEFAULT_MODE);
+ }
+ }
+
+ $this->lessCompiler->setThemeMode($this->pubPath . '/css/modes/'. $mode . '.less');
+ }
+
+ /**
+ * Get all collected files
+ *
+ * @return string[]
+ */
+ protected function getFiles(): array
+ {
+ return array_merge($this->cssFiles, $this->lessCompiler->getLessFiles());
+ }
+
+ /**
+ * Get the stylesheet for PDF export
+ *
+ * @return $this
+ */
+ public static function forPdf()
+ {
+ $styleSheet = new self();
+ $styleSheet->lessCompiler->setTheme(null);
+ $styleSheet->lessCompiler->setThemeMode($styleSheet->pubPath . '/css/modes/none.less');
+ $styleSheet->lessCompiler->addLessFile($styleSheet->pubPath . '/css/pdf/pdfprint.less');
+ // TODO(el): Caching
+ return $styleSheet;
+ }
+
+ /**
+ * Render the stylesheet
+ *
+ * @param bool $minified Whether to compress the stylesheet
+ *
+ * @return string CSS
+ */
+ public function render($minified = false)
+ {
+ if ($minified) {
+ $this->lessCompiler->compress();
+ }
+
+ $css = '';
+ foreach ($this->cssFiles as $cssFile) {
+ $css .= file_get_contents($cssFile);
+ }
+
+ return $css . $this->lessCompiler->render();
+ }
+
+ /**
+ * Send the stylesheet to the client
+ *
+ * Does not cache the stylesheet if the HTTP header Cache-Control or Pragma is set to no-cache.
+ *
+ * @param bool $minified Whether to compress the stylesheet
+ */
+ public static function send($minified = false)
+ {
+ $styleSheet = new self();
+
+ $request = $styleSheet->app->getRequest();
+ $response = $styleSheet->app->getResponse();
+ $response->setHeader('Cache-Control', 'private,no-cache,must-revalidate', true);
+
+ $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache';
+
+ $collectedFiles = $styleSheet->getFiles();
+ if (! $noCache && FileCache::etagMatchesFiles($collectedFiles)) {
+ $response
+ ->setHttpResponseCode(304)
+ ->sendHeaders();
+ return;
+ }
+
+ $etag = FileCache::etagForFiles($collectedFiles);
+
+ $response->setHeader('ETag', $etag, true)
+ ->setHeader('Content-Type', 'text/css', true);
+
+ $cacheFile = 'icinga-' . $etag . ($minified ? '.min' : '') . '.css';
+ $cache = FileCache::instance();
+
+ if (! $noCache && $cache->has($cacheFile)) {
+ $response->setBody($cache->get($cacheFile));
+ } else {
+ $css = $styleSheet->render($minified);
+ $response->setBody($css);
+ $cache->store($cacheFile, $css);
+ }
+
+ $response->sendResponse();
+ }
+
+ /**
+ * Render the stylesheet
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ Logger::error($e);
+ return IcingaException::describe($e);
+ }
+ }
+
+ /**
+ * Get the path to the current LESS theme file
+ *
+ * @param $theme
+ *
+ * @return string|null Return null if self::DEFAULT_THEME is set as theme, path otherwise
+ */
+ public static function getThemeFile($theme)
+ {
+ $app = Icinga::app();
+
+ if ($theme && $theme !== self::DEFAULT_THEME) {
+ if (Hook::has('ThemeLoader')) {
+ try {
+ $path = Hook::first('ThemeLoader')->getThemeFile($theme);
+ } catch (Exception $e) {
+ Logger::error('Failed to call ThemeLoader hook: %s', $e);
+ $path = null;
+ }
+
+ if ($path !== null) {
+ return $path;
+ }
+ }
+
+ if (($pos = strpos($theme, '/')) !== false) {
+ $moduleName = substr($theme, 0, $pos);
+ $theme = substr($theme, $pos + 1);
+ if ($app->getModuleManager()->hasLoaded($moduleName)) {
+ $module = $app->getModuleManager()->getModule($moduleName);
+
+ return $module->getCssDir() . '/themes/' . $theme . '.less';
+ }
+ } else {
+ return $app->getBaseDir('public') . '/css/themes/' . $theme . '.less';
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/library/Icinga/Web/Url.php b/library/Icinga/Web/Url.php
new file mode 100644
index 0000000..c90ca48
--- /dev/null
+++ b/library/Icinga/Web/Url.php
@@ -0,0 +1,806 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Data\Filter\Filter;
+
+/**
+ * Url class that provides convenient access to parameters, allows to modify query parameters and
+ * returns Urls reflecting all changes made to the url and to the parameters.
+ *
+ * Direct instantiation is prohibited and should be done either with @see Url::fromRequest() or
+ * @see Url::fromPath()
+ */
+class Url
+{
+ /**
+ * Whether this url points to an external resource
+ *
+ * @var bool
+ */
+ protected $external;
+
+ /**
+ * An array of all parameters stored in this Url
+ *
+ * @var UrlParams
+ */
+ protected $params;
+
+ /**
+ * The site anchor after the '#'
+ *
+ * @var string
+ */
+ protected $anchor = '';
+
+ /**
+ * The relative path of this Url, without query parameters
+ *
+ * @var string
+ */
+ protected $path = '';
+
+ /**
+ * The basePath of this Url
+ *
+ * @var string
+ */
+ protected $basePath;
+
+ /**
+ * The host of this Url
+ *
+ * @var string
+ */
+ protected $host;
+
+ /**
+ * The port of this Url
+ *
+ * @var string
+ */
+ protected $port;
+
+ /**
+ * The scheme of this Url
+ *
+ * @var string
+ */
+ protected $scheme;
+
+ /**
+ * The username passed with this Url
+ *
+ * @var string
+ */
+ protected $username;
+
+ /**
+ * The password passed with this Url
+ *
+ * @var string
+ */
+ protected $password;
+
+ protected function __construct()
+ {
+ $this->params = UrlParams::fromQueryString(''); // TODO: ::create()
+ }
+
+ /**
+ * Create a new Url class representing the current request
+ *
+ * If $params are given, those will be added to the request's parameters
+ * and overwrite any existing parameters
+ *
+ * @param UrlParams|array $params Parameters that should additionally be considered for the url
+ * @param Request $request A request to use instead of the default one
+ *
+ * @return static
+ */
+ public static function fromRequest($params = array(), $request = null)
+ {
+ if ($request === null) {
+ $request = static::getRequest();
+ }
+
+ $url = new static();
+ $url->setPath(ltrim($request->getPathInfo(), '/'));
+
+ // $urlParams = UrlParams::fromQueryString($request->getQuery());
+ if (isset($_SERVER['QUERY_STRING'])) {
+ $urlParams = UrlParams::fromQueryString($_SERVER['QUERY_STRING']);
+ } else {
+ $urlParams = UrlParams::fromQueryString('');
+ foreach ($request->getQuery() as $k => $v) {
+ $urlParams->set($k, $v);
+ }
+ }
+
+ foreach ($params as $k => $v) {
+ $urlParams->set($k, $v);
+ }
+ $url->setParams($urlParams);
+ $url->setBasePath($request->getBaseUrl());
+ return $url;
+ }
+
+ /**
+ * Return a request object that should be used for determining the URL
+ *
+ * @return Request
+ */
+ protected static function getRequest()
+ {
+ $app = Icinga::app();
+ if ($app->isCli()) {
+ throw new ProgrammingError(
+ 'Url::fromRequest and Url::fromPath are currently not supported for CLI operations'
+ );
+ } else {
+ return $app->getRequest();
+ }
+ }
+
+ /**
+ * Create a new Url class representing the given url
+ *
+ * If $params are given, those will be added to the urls parameters
+ * and overwrite any existing parameters
+ *
+ * @param string $url The string representation of the url to parse
+ * @param array $params An array of parameters that should additionally be considered for the url
+ * @param Request $request A request to use instead of the default one
+ *
+ * @return static
+ */
+ public static function fromPath($url, array $params = array(), $request = null)
+ {
+ if ($request === null) {
+ $request = static::getRequest();
+ }
+
+ if (! is_string($url)) {
+ throw new ProgrammingError(
+ 'url %s is not a string',
+ var_export($url, true)
+ );
+ }
+
+ $urlObject = new static();
+
+ if ($url === '#') {
+ $urlObject->setPath($url);
+ return $urlObject;
+ }
+
+ $urlParts = parse_url($url);
+ if (isset($urlParts['scheme']) && (
+ $urlParts['scheme'] !== $request->getScheme()
+ || (isset($urlParts['host']) && $urlParts['host'] !== $request->getServer('SERVER_NAME'))
+ || (isset($urlParts['port']) && $urlParts['port'] != $request->getServer('SERVER_PORT')))
+ ) {
+ $urlObject->setIsExternal();
+ }
+
+ if (isset($urlParts['path'])) {
+ $urlPath = $urlParts['path'];
+ if ($urlPath && $urlPath[0] === '/') {
+ if ($urlObject->isExternal() || isset($urlParts['user'])) {
+ $urlPath = ltrim($urlPath, '/');
+ } else {
+ $requestBaseUrl = $request->getBaseUrl();
+ if ($requestBaseUrl && $requestBaseUrl !== '/' && strpos($urlPath, $requestBaseUrl) === 0) {
+ $urlPath = ltrim(substr($urlPath, strlen($requestBaseUrl)), '/');
+ $urlObject->setBasePath($requestBaseUrl);
+ }
+ }
+ } elseif (! $urlObject->isExternal()) {
+ $urlObject->setBasePath($request->getBaseUrl());
+ }
+
+ $urlObject->setPath($urlPath);
+ } elseif (! $urlObject->isExternal()) {
+ $urlObject->setBasePath($request->getBaseUrl());
+ }
+
+ // TODO: This has been used by former filter implementation, remove it:
+ if (isset($urlParts['query'])) {
+ $params = UrlParams::fromQueryString($urlParts['query'])->mergeValues($params);
+ }
+ if (isset($urlParts['fragment'])) {
+ $urlObject->setAnchor($urlParts['fragment']);
+ }
+
+ if (isset($urlParts['user']) || $urlObject->isExternal()) {
+ if (isset($urlParts['user'])) {
+ $urlObject->setUsername($urlParts['user']);
+ }
+ if (isset($urlParts['host'])) {
+ $urlObject->setHost($urlParts['host']);
+ }
+ if (isset($urlParts['port'])) {
+ $urlObject->setPort($urlParts['port']);
+ }
+ if (isset($urlParts['scheme'])) {
+ $urlObject->setScheme($urlParts['scheme']);
+ }
+ if (isset($urlParts['pass'])) {
+ $urlObject->setPassword($urlParts['pass']);
+ }
+ }
+
+ $urlObject->setParams($params);
+ return $urlObject;
+ }
+
+ /**
+ * Create a new filter that needs to fullfill the base filter and the optional filter (if it exists)
+ *
+ * @param string $url The url to apply the new filter to
+ * @param Filter $filter The base filter
+ * @param ?Filter $optional The optional filter
+ *
+ * @return static The altered URL containing the new filter
+ * @throws ProgrammingError
+ */
+ public static function urlAddFilterOptional($url, $filter, $optional)
+ {
+ $url = static::fromPath($url);
+ $f = $filter;
+ if (isset($optional)) {
+ $f = Filter::matchAll($filter, $optional);
+ }
+ return $url->setQueryString($f->toQueryString());
+ }
+
+ /**
+ * Add the given filter to the current filter of the URL
+ *
+ * @param Filter $and
+ *
+ * @return $this
+ */
+ public function addFilter($and)
+ {
+ $this->setQueryString(
+ Filter::fromQueryString($this->getQueryString())
+ ->andFilter($and)
+ ->toQueryString()
+ );
+ return $this;
+ }
+
+ /**
+ * Set the basePath for this url
+ *
+ * @param string $basePath New basePath of this url
+ *
+ * @return $this
+ */
+ public function setBasePath($basePath)
+ {
+ $this->basePath = rtrim($basePath, '/ ');
+ return $this;
+ }
+
+ /**
+ * Return the basePath set for this url
+ *
+ * @return string
+ */
+ public function getBasePath()
+ {
+ return $this->basePath;
+ }
+
+ /**
+ * Set the host for this url
+ *
+ * @param string $host New host of this Url
+ *
+ * @return $this
+ */
+ public function setHost($host)
+ {
+ $this->host = $host;
+ return $this;
+ }
+
+ /**
+ * Return the host set for this url
+ *
+ * @return string
+ */
+ public function getHost()
+ {
+ return $this->host;
+ }
+
+ /**
+ * Set the port for this url
+ *
+ * @param string $port New port of this url
+ *
+ * @return $this
+ */
+ public function setPort($port)
+ {
+ $this->port = $port;
+ return $this;
+ }
+
+ /**
+ * Return the port set for this url
+ *
+ * @return string
+ */
+ public function getPort()
+ {
+ return $this->port;
+ }
+
+ /**
+ * Set the scheme for this url
+ *
+ * @param string $scheme The scheme used for this url
+ *
+ * @return $this
+ */
+ public function setScheme($scheme)
+ {
+ $this->scheme = $scheme;
+ return $this;
+ }
+
+ /**
+ * Return the scheme set for this url
+ *
+ * @return string
+ */
+ public function getScheme()
+ {
+ return $this->scheme;
+ }
+
+ /**
+ * Set the relative path of this url, without query parameters
+ *
+ * @param string $path The path to set
+ *
+ * @return $this
+ */
+ public function setPath($path)
+ {
+ $this->path = $path;
+ return $this;
+ }
+
+ /**
+ * Return the relative path of this url, without query parameters
+ *
+ * If you want the relative path with query parameters use getRelativeUrl
+ *
+ * @return string
+ */
+ public function getPath()
+ {
+ return $this->path;
+ }
+
+ /**
+ * Set whether this url points to an external resource
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setIsExternal($state = true)
+ {
+ $this->external = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether this url points to an external resource
+ *
+ * @return bool
+ */
+ public function isExternal()
+ {
+ return $this->external;
+ }
+
+ /**
+ * Set the username passed with this url
+ *
+ * @param string $username The username to set
+ *
+ * @return $this
+ */
+ public function setUsername($username)
+ {
+ $this->username = $username;
+ return $this;
+ }
+
+ /**
+ * Return the username passed with this url
+ *
+ * @return string
+ */
+ public function getUsername()
+ {
+ return $this->username;
+ }
+
+ /**
+ * Set the username passed with this url
+ *
+ * @param string $password The password to set
+ *
+ * @return $this
+ */
+ public function setPassword($password)
+ {
+ $this->password = $password;
+ return $this;
+ }
+
+ /**
+ * Return the password passed with this url
+ *
+ * @return string
+ */
+ public function getPassword()
+ {
+ return $this->password;
+ }
+
+ /**
+ * Return the relative url
+ *
+ * @return string
+ */
+ public function getRelativeUrl($separator = '&')
+ {
+ $path = $this->buildPathQueryAndFragment($separator);
+ if ($path && $path[0] === '/') {
+ return '';
+ }
+
+ return $path;
+ }
+
+ /**
+ * Return this url's path with its query parameters and fragment as string
+ *
+ * @return string
+ */
+ protected function buildPathQueryAndFragment($querySeparator)
+ {
+ $anchor = $this->getAnchor();
+ if ($anchor) {
+ $anchor = '#' . $anchor;
+ }
+
+ $query = $this->getQueryString($querySeparator);
+ if ($query) {
+ $query = '?' . $query;
+ }
+
+ return $this->getPath() . $query . $anchor;
+ }
+
+ public function setQueryString($queryString)
+ {
+ $this->params = UrlParams::fromQueryString($queryString);
+ return $this;
+ }
+
+ public function getQueryString($separator = null)
+ {
+ return $this->params->toString($separator);
+ }
+
+ /**
+ * Return the absolute url with query parameters as a string
+ *
+ * @return string
+ */
+ public function getAbsoluteUrl($separator = '&')
+ {
+ $path = $this->buildPathQueryAndFragment($separator);
+ if ($path && ($path === '#' || $path[0] === '/')) {
+ return $path;
+ }
+
+ $basePath = $this->getBasePath();
+ if (! $basePath) {
+ $basePath = '/';
+ }
+
+ if ($this->getUsername() || $this->isExternal()) {
+ $urlString = '';
+ if ($this->getScheme()) {
+ $urlString .= $this->getScheme() . '://';
+ }
+ if ($this->getPassword()) {
+ $urlString .= $this->getUsername() . ':' . $this->getPassword() . '@';
+ } elseif ($this->getUsername()) {
+ $urlString .= $this->getUsername() . '@';
+ }
+ if ($this->getHost()) {
+ $urlString .= $this->getHost();
+ }
+ if ($this->getPort()) {
+ $urlString .= ':' . $this->getPort();
+ }
+
+ return $urlString . $basePath . ($basePath !== '/' && $path ? '/' : '') . $path;
+ } else {
+ return $basePath . ($basePath !== '/' && $path ? '/' : '') . $path;
+ }
+ }
+
+ /**
+ * Add a set of parameters to the query part if the keys don't exist yet
+ *
+ * @param array $params The parameters to add
+ *
+ * @return $this
+ */
+ public function addParams(array $params)
+ {
+ foreach ($params as $k => $v) {
+ $this->params->add($k, $v);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set and overwrite the given params if one if the same key already exists
+ *
+ * @param array $params The parameters to set
+ *
+ * @return $this
+ */
+ public function overwriteParams(array $params)
+ {
+ foreach ($params as $k => $v) {
+ $this->params->set($k, $v);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Overwrite the parameters used in the query part
+ *
+ * @param UrlParams|array $params The new parameters to use for the query part
+ *
+ * @return $this
+ */
+ public function setParams($params)
+ {
+ if ($params instanceof UrlParams) {
+ $this->params = $params;
+ } elseif (is_array($params)) {
+ $urlParams = UrlParams::fromQueryString('');
+ foreach ($params as $k => $v) {
+ $urlParams->set($k, $v);
+ }
+ $this->params = $urlParams;
+ } else {
+ throw new ProgrammingError(
+ 'Url params needs to be either an array or an UrlParams instance'
+ );
+ }
+ return $this;
+ }
+
+ /**
+ * Return all parameters that will be used in the query part
+ *
+ * @return UrlParams An instance of UrlParam containing all parameters
+ */
+ public function getParams()
+ {
+ return $this->params;
+ }
+
+ /**
+ * Return true if a urls' query parameter exists, otherwise false
+ *
+ * @param string $param The url parameter name to check
+ *
+ * @return bool
+ */
+ public function hasParam($param)
+ {
+ return $this->params->has($param);
+ }
+
+ /**
+ * Return a url's query parameter if it exists, otherwise $default
+ *
+ * @param string $param A query parameter name to return if existing
+ * @param mixed $default A value to return when the parameter doesn't exist
+ *
+ * @return mixed
+ */
+ public function getParam($param, $default = null)
+ {
+ return $this->params->get($param, $default);
+ }
+
+ /**
+ * Set a single parameter, overwriting any existing one with the same name
+ *
+ * @param string $param The query parameter name
+ * @param array|string|bool $value An array or string to set as the parameter value
+ *
+ * @return $this
+ */
+ public function setParam($param, $value = true)
+ {
+ $this->params->set($param, $value);
+ return $this;
+ }
+
+ /**
+ * Set the url anchor-part
+ *
+ * @param string $anchor The site's anchor string without the '#'
+ *
+ * @return $this
+ */
+ public function setAnchor($anchor)
+ {
+ $this->anchor = $anchor;
+ return $this;
+ }
+
+ /**
+ * Return the url anchor-part
+ *
+ * @return string The site's anchor string without the '#'
+ */
+ public function getAnchor()
+ {
+ return $this->anchor;
+ }
+
+ /**
+ * Remove provided key (if string) or keys (if array of string) from the query parameter array
+ *
+ * @param string|array $keyOrArrayOfKeys An array of strings or a string representing the key(s)
+ * of the parameters to be removed
+ * @return $this
+ */
+ public function remove($keyOrArrayOfKeys)
+ {
+ $this->params->remove($keyOrArrayOfKeys);
+ return $this;
+ }
+
+ /**
+ * Shift a query parameter from this URL if it exists, otherwise $default
+ *
+ * @param string $param Parameter name
+ * @param mixed $default Default value in case $param does not exist
+ *
+ * @return mixed
+ */
+ public function shift($param, $default = null)
+ {
+ return $this->params->shift($param, $default);
+ }
+
+ /**
+ * Whether the given URL matches this URL object
+ *
+ * This does an exact match, parameters MUST be in the same order
+ *
+ * @param Url|string $url the URL to compare against
+ *
+ * @return bool whether the URL matches
+ */
+ public function matches($url)
+ {
+ if (! $url instanceof static) {
+ $url = static::fromPath($url);
+ }
+ return (string) $url === (string) $this;
+ }
+
+ /**
+ * Return a copy of this url without the parameter given
+ *
+ * The argument can be either a single query parameter name or an array of parameter names to
+ * remove from the query list
+ *
+ * @param string|array $keyOrArrayOfKeys A single string or an array containing parameter names
+ *
+ * @return static
+ */
+ public function getUrlWithout($keyOrArrayOfKeys)
+ {
+ return $this->without($keyOrArrayOfKeys);
+ }
+
+ public function without($keyOrArrayOfKeys)
+ {
+ $url = clone($this);
+ $url->remove($keyOrArrayOfKeys);
+ return $url;
+ }
+
+ /**
+ * Return a copy of this url with the given parameter(s)
+ *
+ * The argument can be either a single query parameter name or an array of parameter names to
+ * remove from the query list
+ *
+ * @param string|array $param A single string or an array containing parameter names
+ * @param mixed $values an optional values array
+ *
+ * @return static
+ */
+ public function with($param, $values = null)
+ {
+ $url = clone($this);
+ $url->params->mergeValues($param, $values);
+ return $url;
+ }
+
+ /**
+ * Return a copy of this url with only the given parameter(s)
+ *
+ * The argument can be either a single query parameter name or
+ * an array of parameter names to keep on on the query
+ *
+ * @param string|array $keyOrArrayOfKeys
+ *
+ * @return static
+ */
+ public function onlyWith($keyOrArrayOfKeys)
+ {
+ if (! is_array($keyOrArrayOfKeys)) {
+ $keyOrArrayOfKeys = [$keyOrArrayOfKeys];
+ }
+
+ $url = clone $this;
+ foreach ($url->getParams()->toArray(false) as $param => $value) {
+ if (is_int($param)) {
+ $param = $value;
+ }
+
+ if (! in_array($param, $keyOrArrayOfKeys, true)) {
+ $url->remove($param);
+ }
+ }
+
+ return $url;
+ }
+
+ public function __clone()
+ {
+ $this->params = clone $this->params;
+ }
+
+ /**
+ * Alias for @see Url::getAbsoluteUrl()
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return htmlspecialchars($this->getAbsoluteUrl(), ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true);
+ }
+}
diff --git a/library/Icinga/Web/UrlParams.php b/library/Icinga/Web/UrlParams.php
new file mode 100644
index 0000000..2265235
--- /dev/null
+++ b/library/Icinga/Web/UrlParams.php
@@ -0,0 +1,433 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Exception\MissingParameterException;
+
+class UrlParams
+{
+ protected $separator = '&';
+
+ protected $params = array();
+
+ protected $index = array();
+
+ public function isEmpty()
+ {
+ return empty($this->index);
+ }
+
+ public function setSeparator($separator)
+ {
+ $this->separator = $separator;
+ return $this;
+ }
+
+ /**
+ * Get the given parameter
+ *
+ * Returns the last URL param if defined multiple times, $default if not
+ * given at all
+ *
+ * @param string $param The parameter you're interested in
+ * @param string|int|bool|null $default An optional default value
+ *
+ * @return mixed
+ */
+ public function get($param, $default = null)
+ {
+ if (! $this->has($param)) {
+ return $default;
+ }
+
+ return rawurldecode($this->params[ end($this->index[$param]) ][ 1 ]);
+ }
+
+ /**
+ * Require a parameter
+ *
+ * @param string $name Name of the parameter
+ * @param bool $strict Whether the parameter's value must not be the empty string
+ *
+ * @return mixed
+ *
+ * @throws MissingParameterException If the parameter was not given
+ */
+ public function getRequired($name, $strict = true)
+ {
+ if ($this->has($name)) {
+ $value = $this->get($name);
+ if (! $strict || strlen($value) > 0) {
+ return $value;
+ }
+ }
+ $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name);
+ $e->setParameter($name);
+ throw $e;
+ }
+
+ /**
+ * Get all instances of the given parameter
+ *
+ * Returns an array containing all values defined for a given parameter,
+ * $default if none.
+ *
+ * @param string $param The parameter you're interested in
+ * @param array $default An optional default value
+ *
+ * @return mixed
+ */
+ public function getValues($param, $default = array())
+ {
+ if (! $this->has($param)) {
+ return $default;
+ }
+
+ $ret = array();
+ foreach ($this->index[$param] as $key) {
+ $ret[] = rawurldecode($this->params[$key][1]);
+ }
+ return $ret;
+ }
+
+ /**
+ * Whether the given parameter exists
+ *
+ * Returns true if such a parameter has been defined, false otherwise.
+ *
+ * @param string $param The parameter you're interested in
+ *
+ * @return boolean
+ */
+ public function has($param)
+ {
+ return array_key_exists($param, $this->index);
+ }
+
+ /**
+ * Get and remove the given parameter
+ *
+ * Returns the last URL param if defined multiple times, $default if not
+ * given at all. The parameter will be removed from this object.
+ *
+ * @param string $param The parameter you're interested in
+ * @param string $default An optional default value
+ *
+ * @return mixed
+ */
+ public function shift($param = null, $default = null)
+ {
+ if ($param === null) {
+ if (empty($this->params)) {
+ return $default;
+ }
+ $ret = array_shift($this->params);
+ $ret[0] = rawurldecode($ret[0]);
+ $ret[1] = rawurldecode($ret[1]);
+ } else {
+ if (! $this->has($param)) {
+ return $default;
+ }
+ $key = reset($this->index[$param]);
+ $ret = rawurldecode($this->params[$key][1]);
+ unset($this->params[$key]);
+ }
+
+ $this->reIndexAll();
+ return $ret;
+ }
+
+ /**
+ * Require and remove a parameter
+ *
+ * @param string $name Name of the parameter
+ * @param bool $strict Whether the parameter's value must not be the empty string
+ *
+ * @return mixed
+ *
+ * @throws MissingParameterException If the parameter was not given
+ */
+ public function shiftRequired($name, $strict = true)
+ {
+ if ($this->has($name)) {
+ $value = $this->get($name);
+ if (! $strict || strlen($value) > 0) {
+ $this->shift($name);
+ return $value;
+ }
+ }
+ $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name);
+ $e->setParameter($name);
+ throw $e;
+ }
+
+ public function addEncoded($param, $value = true)
+ {
+ $this->params[] = array($param, $this->cleanupValue($value));
+ $this->indexLastOne();
+ return $this;
+ }
+
+ protected function urlEncode($value)
+ {
+ return rawurlencode($value instanceof Url ? $value->getAbsoluteUrl() : (string) $value);
+ }
+
+ /**
+ * Add the given parameter with the given value
+ *
+ * This will add the given parameter, regardless of whether it already
+ * exists.
+ *
+ * @param string $param The parameter you're interested in
+ * @param string|bool $value The value to be stored
+ *
+ * @return $this
+ */
+ public function add($param, $value = true)
+ {
+ return $this->addEncoded($this->urlEncode($param), $this->urlEncode($value));
+ }
+
+ /**
+ * Adds a list of parameters
+ *
+ * This may be used with either a list of values for a single parameter or
+ * with a list of parameter / value pairs.
+ *
+ * @param string|array $param Parameter name or param/value list
+ * @param ?array $value The value to be stored
+ *
+ * @return $this
+ */
+ public function addValues($param, $values = null)
+ {
+ if ($values === null && is_array($param)) {
+ foreach ($param as $k => $v) {
+ $this->add($k, $v);
+ }
+ } else {
+ foreach ($values as $value) {
+ $this->add($param, $value);
+ }
+ }
+
+ return $this;
+ }
+
+ protected function clearValues()
+ {
+ $this->params = array();
+ $this->index = array();
+ }
+
+ public function mergeValues($param, $values = null)
+ {
+ if ($values === null && is_array($param)) {
+ foreach ($param as $k => $v) {
+ $this->set($k, $v);
+ }
+ } else {
+ if (! is_array($values)) {
+ $values = array($values);
+ }
+ foreach ($values as $value) {
+ $this->set($param, $value);
+ }
+ }
+
+ return $this;
+ }
+
+ public function setValues($param, $values = null)
+ {
+ $this->clearValues();
+ return $this->addValues($param, $values);
+ }
+
+ /**
+ * Add the given parameter with the given value in front of all other values
+ *
+ * This will add the given parameter in front of all others, regardless of
+ * whether it already exists.
+ *
+ * @param string $param The parameter you're interested in
+ * @param string $value The value to be stored
+ *
+ * @return $this
+ */
+ public function unshift($param, $value)
+ {
+ array_unshift($this->params, array($this->urlEncode($param), $this->urlEncode($value)));
+ $this->reIndexAll();
+ return $this;
+ }
+
+ /**
+ * Set the given parameter with the given value
+ *
+ * This will set the given parameter, and override eventually existing ones.
+ *
+ * @param string $param The parameter you want to set
+ * @param string $value The value to be stored
+ *
+ * @return $this
+ */
+ public function set($param, $value)
+ {
+ if (! $this->has($param)) {
+ return $this->add($param, $value);
+ }
+
+ while (count($this->index[$param]) > 1) {
+ $remove = array_pop($this->index[$param]);
+ unset($this->params[$remove]);
+ }
+
+ $this->params[$this->index[$param][0]] = array(
+ $this->urlEncode($param),
+ $this->urlEncode($this->cleanupValue($value))
+ );
+ $this->reIndexAll();
+
+ return $this;
+ }
+
+ public function remove($param)
+ {
+ $changed = false;
+
+ if (! is_array($param)) {
+ $param = array($param);
+ }
+
+ foreach ($param as $p) {
+ if ($this->has($p)) {
+ foreach ($this->index[$p] as $key) {
+ unset($this->params[$key]);
+ }
+ $changed = true;
+ }
+ }
+
+ if ($changed) {
+ $this->reIndexAll();
+ }
+
+ return $this;
+ }
+
+ public function without($param)
+ {
+ $params = clone $this;
+ return $params->remove($param);
+ }
+
+ // TODO: push, pop?
+
+ protected function indexLastOne()
+ {
+ end($this->params);
+ $key = key($this->params);
+ $param = $this->params[$key][0];
+ $this->addParamToIndex($param, $key);
+ }
+
+ protected function addParamToIndex($param, $key)
+ {
+ if (! $this->has($param)) {
+ $this->index[$param] = array();
+ }
+ $this->index[$param][] = $key;
+ }
+
+ protected function reIndexAll()
+ {
+ $this->index = array();
+ $this->params = array_values($this->params);
+ foreach ($this->params as $key => & $param) {
+ $this->addParamToIndex($param[0], $key);
+ }
+ }
+
+ protected function cleanupValue($value)
+ {
+ return is_bool($value) ? $value : (string) $value;
+ }
+
+ protected function parseQueryString($queryString)
+ {
+ $parts = preg_split('~&~', $queryString, -1, PREG_SPLIT_NO_EMPTY);
+ foreach ($parts as $part) {
+ $this->parseQueryStringPart($part);
+ }
+ }
+
+ protected function parseQueryStringPart($part)
+ {
+ if (strpos($part, '=') === false) {
+ $this->addEncoded($part, true);
+ } else {
+ list($key, $val) = preg_split('/=/', $part, 2);
+ $this->addEncoded($key, $val);
+ }
+ }
+
+ /**
+ * Return the parameters of this url as sequenced or associative array
+ *
+ * @param bool $sequenced
+ *
+ * @return array
+ */
+ public function toArray($sequenced = true)
+ {
+ if ($sequenced) {
+ return $this->params;
+ }
+
+ $params = array();
+ foreach ($this->params as $param) {
+ if ($param[1] === true) {
+ $params[] = $param[0];
+ } else {
+ $params[$param[0]] = $param[1];
+ }
+ }
+
+ return $params;
+ }
+
+ public function toString($separator = null)
+ {
+ if ($separator === null) {
+ $separator = $this->separator;
+ }
+ $parts = array();
+ foreach ($this->params as $p) {
+ if ($p[1] === true) {
+ $parts[] = $p[0];
+ } else {
+ $parts[] = $p[0] . '=' . $p[1];
+ }
+ }
+ return implode($separator, $parts);
+ }
+
+ public function __toString()
+ {
+ return $this->toString();
+ }
+
+ public static function fromQueryString($queryString = null)
+ {
+ if ($queryString === null) {
+ $queryString = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
+ }
+ $params = new static();
+ $params->parseQueryString($queryString);
+
+ return $params;
+ }
+}
diff --git a/library/Icinga/Web/UserAgent.php b/library/Icinga/Web/UserAgent.php
new file mode 100644
index 0000000..71c1a8b
--- /dev/null
+++ b/library/Icinga/Web/UserAgent.php
@@ -0,0 +1,86 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web;
+
+/**
+ * Class UserAgent
+ *
+ * This class helps to get user agent information like OS type and browser name
+ *
+ * @package Icinga\Web
+ */
+class UserAgent
+{
+ /**
+ * $_SERVER['HTTP_USER_AGENT'] output string
+ *
+ * @var string|null
+ */
+ private $agent;
+
+ public function __construct($agent = null)
+ {
+ $this->agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null;
+
+ if ($agent) {
+ $this->agent = $agent->http_user_agent;
+ }
+ }
+
+ /**
+ * Return $_SERVER['HTTP_USER_AGENT'] output string of given or current device
+ *
+ * @return string
+ */
+ public function getAgent()
+ {
+ return $this->agent;
+ }
+
+ /**
+ * Get Browser name
+ *
+ * @return string Browser name or unknown if not found
+ */
+ public function getBrowser()
+ {
+ // key => regex value
+ $browsers = [
+ "Internet Explorer" => "/MSIE(.*)/i",
+ "Seamonkey" => "/Seamonkey(.*)/i",
+ "MS Edge" => "/Edg(.*)/i",
+ "Opera" => "/Opera(.*)/i",
+ "Opera Browser" => "/OPR(.*)/i",
+ "Chromium" => "/Chromium(.*)/i",
+ "Firefox" => "/Firefox(.*)/i",
+ "Google Chrome" => "/Chrome(.*)/i",
+ "Safari" => "/Safari(.*)/i"
+ ];
+ //TODO find a way to return also the version of the browser
+ foreach ($browsers as $browser => $regex) {
+ if (preg_match($regex, $this->agent)) {
+ return $browser;
+ }
+ }
+
+ return 'unknown';
+ }
+
+ /**
+ * Get Operating system information
+ *
+ * @return string os information
+ */
+ public function getOs()
+ {
+ // get string before the first appearance of ')'
+ $device = strstr($this->agent, ')', true);
+ if (! $device) {
+ return 'unknown';
+ }
+
+ // return string after the first appearance of '('
+ return substr($device, strpos($device, '(') + 1);
+ }
+}
diff --git a/library/Icinga/Web/View.php b/library/Icinga/Web/View.php
new file mode 100644
index 0000000..2c80d1d
--- /dev/null
+++ b/library/Icinga/Web/View.php
@@ -0,0 +1,254 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Closure;
+use Icinga\Application\Icinga;
+use ipl\I18n\Translation;
+use Zend_View_Abstract;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Icinga view
+ *
+ * @method Url href($path = null, $params = null) {
+ * @param Url|string|null $path
+ * @param string[]|null $params
+ * }
+ *
+ * @method Url url($path = null, $params = null) {
+ * @param Url|string|null $path
+ * @param string[]|null $params
+ * }
+ *
+ * @method Url qlink($title, $url, $params = null, $properties = null, $escape = true) {
+ * @param string $title
+ * @param Url|string|null $url
+ * @param string[]|null $params
+ * @param string[]|null $properties
+ * @param bool $escape
+ * }
+ *
+ * @method string img($url, $params = null, array $properties = array()) {
+ * @param Url|string|null $url
+ * @param string[]|null $params
+ * @param string[] $properties
+ * }
+ *
+ * @method string icon($img, $title = null, array $properties = array()) {
+ * @param string $img
+ * @param string|null $title
+ * @param string[] $properties
+ * }
+ *
+ * @method string propertiesToString($properties) {
+ * @param string[] $properties
+ * }
+ *
+ * @method string attributeToString($key, $value) {
+ * @param string $key
+ * @param string $value
+ * }
+ */
+class View extends Zend_View_Abstract
+{
+ use Translation;
+
+ /**
+ * Charset to be used - we only support UTF-8
+ */
+ const CHARSET = 'UTF-8';
+
+ /**
+ * Registered helper functions
+ */
+ private $helperFunctions = array();
+
+ /**
+ * Authentication manager
+ *
+ * @var Auth|null
+ */
+ private $auth;
+
+ /**
+ * Create a new view object
+ *
+ * @param array $config
+ * @see Zend_View_Abstract::__construct
+ */
+ public function __construct($config = array())
+ {
+ $config['helperPath']['Icinga\\Web\\View\\Helper\\'] = Icinga::app()->getLibraryDir('Icinga/Web/View/Helper');
+
+ parent::__construct($config);
+ }
+
+ /**
+ * Initialize the view
+ *
+ * @see Zend_View_Abstract::init
+ */
+ public function init()
+ {
+ $this->loadGlobalHelpers();
+ }
+
+ /**
+ * Escape the given value top be safely used in view scripts
+ *
+ * @param ?string $var The output to be escaped
+ * @return string
+ */
+ public function escape($var)
+ {
+ return htmlspecialchars($var ?? '', ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, self::CHARSET, true);
+ }
+
+ /**
+ * Whether a specific helper (closure) has been registered
+ *
+ * @param string $name The desired function name
+ * @return boolean
+ */
+ public function hasHelperFunction($name)
+ {
+ return array_key_exists($name, $this->helperFunctions);
+ }
+
+ /**
+ * Add a new helper function
+ *
+ * @param string $name The desired function name
+ * @param Closure $function An anonymous function
+ * @return $this
+ */
+ public function addHelperFunction($name, Closure $function)
+ {
+ if ($this->hasHelperFunction($name)) {
+ throw new ProgrammingError(
+ 'Cannot assign the same helper function twice: "%s"',
+ $name
+ );
+ }
+
+ $this->helperFunctions[$name] = $function;
+ return $this;
+ }
+
+ /**
+ * Set or overwrite a helper function
+ *
+ * @param string $name
+ * @param Closure $function
+ *
+ * @return $this
+ */
+ public function setHelperFunction($name, Closure $function)
+ {
+ $this->helperFunctions[$name] = $function;
+ return $this;
+ }
+
+ /**
+ * Drop a helper function
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function dropHelperFunction($name)
+ {
+ unset($this->helperFunctions[$name]);
+ return $this;
+ }
+
+ /**
+ * Call a helper function
+ *
+ * @param string $name The desired function name
+ * @param Array $args Function arguments
+ * @return mixed
+ */
+ public function callHelperFunction($name, $args)
+ {
+ return call_user_func_array(
+ $this->helperFunctions[$name],
+ $args
+ );
+ }
+
+ /**
+ * Load helpers
+ */
+ private function loadGlobalHelpers()
+ {
+ $pattern = dirname(__FILE__) . '/View/helpers/*.php';
+ $files = glob($pattern);
+ foreach ($files as $file) {
+ require_once $file;
+ }
+ }
+
+ /**
+ * Get the authentication manager
+ *
+ * @return Auth
+ */
+ public function Auth()
+ {
+ if ($this->auth === null) {
+ $this->auth = Auth::getInstance();
+ }
+ return $this->auth;
+ }
+
+ /**
+ * Whether the current user has the given permission
+ *
+ * @param string $permission Name of the permission
+ *
+ * @return bool
+ */
+ public function hasPermission($permission)
+ {
+ return $this->Auth()->hasPermission($permission);
+ }
+
+ /**
+ * Use to include the view script in a scope that only allows public
+ * members.
+ *
+ * @return mixed
+ *
+ * @see Zend_View_Abstract::run
+ */
+ protected function _run()
+ {
+ foreach ($this->getVars() as $k => $v) {
+ // Exporting global variables to view scripts:
+ $$k = $v;
+ }
+
+ include func_get_arg(0);
+ }
+
+ /**
+ * Accesses a helper object from within a script
+ *
+ * @param string $name
+ * @param array $args
+ *
+ * @return string
+ */
+ public function __call($name, $args)
+ {
+ if ($this->hasHelperFunction($name)) {
+ return $this->callHelperFunction($name, $args);
+ } else {
+ return parent::__call($name, $args);
+ }
+ }
+}
diff --git a/library/Icinga/Web/View/AppHealth.php b/library/Icinga/Web/View/AppHealth.php
new file mode 100644
index 0000000..c66ca05
--- /dev/null
+++ b/library/Icinga/Web/View/AppHealth.php
@@ -0,0 +1,89 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\View;
+
+use Icinga\Application\Hook\HealthHook;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Web\Common\BaseTarget;
+use ipl\Web\Widget\Link;
+use Traversable;
+
+class AppHealth extends Table
+{
+ use BaseTarget;
+
+ protected $defaultAttributes = ['class' => ['app-health', 'common-table', 'table-row-selectable']];
+
+ /** @var Traversable */
+ protected $data;
+
+ public function __construct(Traversable $data)
+ {
+ $this->data = $data;
+
+ $this->setBaseTarget('_next');
+ }
+
+ protected function assemble()
+ {
+ foreach ($this->data as $row) {
+ $this->add(Table::tr([
+ Table::th(HtmlElement::create('span', ['class' => [
+ 'ball',
+ 'ball-size-xl',
+ $this->getStateClass($row->state)
+ ]])),
+ Table::td([
+ new HtmlElement('header', null, FormattedString::create(
+ t('%s by %s is %s', '<check> by <module> is <state-text>'),
+ $row->url
+ ? new Link(HtmlElement::create('span', null, $row->name), $row->url)
+ : HtmlElement::create('span', null, $row->name),
+ HtmlElement::create('span', null, $row->module),
+ HtmlElement::create('span', null, $this->getStateText($row->state))
+ )),
+ HtmlElement::create('section', null, $row->message)
+ ])
+ ]));
+ }
+ }
+
+ protected function getStateClass($state)
+ {
+ if ($state === null) {
+ $state = HealthHook::STATE_UNKNOWN;
+ }
+
+ switch ($state) {
+ case HealthHook::STATE_OK:
+ return 'state-ok';
+ case HealthHook::STATE_WARNING:
+ return 'state-warning';
+ case HealthHook::STATE_CRITICAL:
+ return 'state-critical';
+ case HealthHook::STATE_UNKNOWN:
+ return 'state-unknown';
+ }
+ }
+
+ protected function getStateText($state)
+ {
+ if ($state === null) {
+ $state = t('UNKNOWN');
+ }
+
+ switch ($state) {
+ case HealthHook::STATE_OK:
+ return t('OK');
+ case HealthHook::STATE_WARNING:
+ return t('WARNING');
+ case HealthHook::STATE_CRITICAL:
+ return t('CRITICAL');
+ case HealthHook::STATE_UNKNOWN:
+ return t('UNKNOWN');
+ }
+ }
+}
diff --git a/library/Icinga/Web/View/Helper/IcingaCheckbox.php b/library/Icinga/Web/View/Helper/IcingaCheckbox.php
new file mode 100644
index 0000000..07cf01f
--- /dev/null
+++ b/library/Icinga/Web/View/Helper/IcingaCheckbox.php
@@ -0,0 +1,30 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\View\Helper;
+
+class IcingaCheckbox extends \Zend_View_Helper_FormCheckbox
+{
+ public function icingaCheckbox($name, $value = null, $attribs = null, array $checkedOptions = null)
+ {
+ if (! isset($attribs['id'])) {
+ $attribs['id'] = $this->view->protectId('icingaCheckbox_' . $name);
+ }
+
+ $attribs['class'] = (isset($attribs['class']) ? $attribs['class'] . ' ' : '') . 'sr-only';
+ $html = parent::formCheckbox($name, $value, $attribs, $checkedOptions);
+
+ $class = 'toggle-switch';
+ if (isset($attribs['disabled'])) {
+ $class .= ' disabled';
+ }
+
+ return $html
+ . '<label for="'
+ . $attribs['id']
+ . '" aria-hidden="true"'
+ . ' class="'
+ . $class
+ . '"><span class="toggle-slider"></span></label>';
+ }
+}
diff --git a/library/Icinga/Web/View/PrivilegeAudit.php b/library/Icinga/Web/View/PrivilegeAudit.php
new file mode 100644
index 0000000..fcb4083
--- /dev/null
+++ b/library/Icinga/Web/View/PrivilegeAudit.php
@@ -0,0 +1,622 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\View;
+
+use Icinga\Authentication\Role;
+use Icinga\Forms\Security\RoleForm;
+use Icinga\Util\StringHelper;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTarget;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+class PrivilegeAudit extends BaseHtmlElement
+{
+ use BaseTarget;
+
+ /** @var string */
+ const UNRESTRICTED_PERMISSION = 'unrestricted';
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'privilege-audit'];
+
+ /** @var Role[] */
+ protected $roles;
+
+ public function __construct(array $roles)
+ {
+ $this->roles = $roles;
+ $this->setBaseTarget('_next');
+ }
+
+ protected function auditPermission($permission)
+ {
+ $grantedBy = [];
+ $refusedBy = [];
+ foreach ($this->roles as $role) {
+ if ($permission === self::UNRESTRICTED_PERMISSION) {
+ if ($role->isUnrestricted()) {
+ $grantedBy[] = $role->getName();
+ }
+ } elseif ($role->denies($permission)) {
+ $refusedBy[] = $role->getName();
+ } elseif ($role->grants($permission, false, false)) {
+ $grantedBy[] = $role->getName();
+ }
+ }
+
+ $header = new HtmlElement('summary');
+ if (! empty($refusedBy)) {
+ $header->add([
+ new Icon('times-circle', ['class' => 'refused']),
+ count($refusedBy) > 2
+ ? sprintf(
+ tp(
+ 'Refused by %s and %s as well as one other',
+ 'Refused by %s and %s as well as %d others',
+ count($refusedBy) - 2
+ ),
+ $refusedBy[0],
+ $refusedBy[1],
+ count($refusedBy) - 2
+ )
+ : sprintf(
+ tp('Refused by %s', 'Refused by %s and %s', count($refusedBy)),
+ ...$refusedBy
+ )
+ ]);
+ } elseif (! empty($grantedBy)) {
+ $header->add([
+ new Icon('check-circle', ['class' => 'granted']),
+ count($grantedBy) > 2
+ ? sprintf(
+ tp(
+ 'Granted by %s and %s as well as one other',
+ 'Granted by %s and %s as well as %d others',
+ count($grantedBy) - 2
+ ),
+ $grantedBy[0],
+ $grantedBy[1],
+ count($grantedBy) - 2
+ )
+ : sprintf(
+ tp('Granted by %s', 'Granted by %s and %s', count($grantedBy)),
+ ...$grantedBy
+ )
+ ]);
+ } else {
+ $header->add([new Icon('minus-circle'), t('Not granted or refused by any role')]);
+ }
+
+ $vClass = null;
+ $rolePaths = [];
+ foreach (array_reverse($this->roles) as $role) {
+ if (! in_array($role->getName(), $refusedBy, true) && ! in_array($role->getName(), $grantedBy, true)) {
+ continue;
+ }
+
+ /** @var Role[] $rolesReversed */
+ $rolesReversed = [];
+
+ do {
+ array_unshift($rolesReversed, $role);
+ } while (($role = $role->getParent()) !== null);
+
+ $path = new HtmlElement('ol');
+
+ $class = null;
+ $setInitiator = false;
+ foreach ($rolesReversed as $role) {
+ $granted = false;
+ $refused = false;
+ $icon = new Icon('minus-circle');
+ if ($permission === self::UNRESTRICTED_PERMISSION) {
+ if ($role->isUnrestricted()) {
+ $granted = true;
+ $icon = new Icon('check-circle', ['class' => 'granted']);
+ }
+ } elseif ($role->denies($permission, true)) {
+ $refused = true;
+ $icon = new Icon('times-circle', ['class' => 'refused']);
+ } elseif ($role->grants($permission, true, false)) {
+ $granted = true;
+ $icon = new Icon('check-circle', ['class' => 'granted']);
+ }
+
+ $connector = null;
+ if ($role->getParent() !== null) {
+ $connector = HtmlElement::create('li', ['class' => ['connector', $class]]);
+ if ($setInitiator) {
+ $setInitiator = false;
+ $connector->getAttributes()->add('class', 'initiator');
+ }
+
+ $path->prependHtml($connector);
+ }
+
+ $path->prependHtml(new HtmlElement('li', Attributes::create([
+ 'class' => ['role', $class],
+ 'title' => $role->getName()
+ ]), new Link([$icon, $role->getName()], Url::fromPath('role/edit', ['role' => $role->getName()]))));
+
+ if ($refused) {
+ $setInitiator = $class !== 'refused';
+ $class = 'refused';
+ } elseif ($granted) {
+ $setInitiator = $class === null;
+ $class = $class ?: 'granted';
+ }
+ }
+
+ if ($vClass === null || $vClass === 'granted') {
+ $vClass = $class;
+ }
+
+ array_unshift($rolePaths, $path->prepend([
+ empty($rolePaths) ? null : HtmlElement::create('li', ['class' => ['vertical-line', $vClass]]),
+ new HtmlElement('li', Attributes::create(['class' => [
+ 'connector',
+ $class,
+ $setInitiator ? 'initiator' : null
+ ]]))
+ ]));
+ }
+
+ if (empty($rolePaths)) {
+ return [
+ empty($refusedBy) ? (empty($grantedBy) ? null : true) : false,
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'inheritance-paths']),
+ $header->setTag('div')
+ )
+ ];
+ }
+
+ return [
+ empty($refusedBy) ? (empty($grantedBy) ? null : true) : false,
+ HtmlElement::create('details', [
+ 'class' => ['collapsible', 'inheritance-paths'],
+ 'data-no-persistence' => true,
+ 'open' => getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf'
+ ], [
+ $header->addAttributes(['class' => 'collapsible-control']),
+ $rolePaths
+ ])
+ ];
+ }
+
+ protected function auditRestriction($restriction)
+ {
+ $restrictedBy = [];
+ $restrictions = [];
+ foreach ($this->roles as $role) {
+ if ($role->isUnrestricted()) {
+ $restrictedBy = [];
+ $restrictions = [];
+ break;
+ }
+
+ foreach ($this->collectRestrictions($role, $restriction) as $role => $roleRestriction) {
+ $restrictedBy[] = $role;
+ $restrictions[] = $roleRestriction;
+ }
+ }
+
+ $header = new HtmlElement('summary');
+ if (! empty($restrictedBy)) {
+ $header->add([
+ new Icon('filter', ['class' => 'restricted']),
+ count($restrictedBy) > 2
+ ? sprintf(
+ tp(
+ 'Restricted by %s and %s as well as one other',
+ 'Restricted by %s and %s as well as %d others',
+ count($restrictedBy) - 2
+ ),
+ $restrictedBy[0]->getName(),
+ $restrictedBy[1]->getName(),
+ count($restrictedBy) - 2
+ )
+ : sprintf(
+ tp('Restricted by %s', 'Restricted by %s and %s', count($restrictedBy)),
+ ...array_map(function ($role) {
+ return $role->getName();
+ }, $restrictedBy)
+ )
+ ]);
+ } else {
+ $header->add([new Icon('filter'), t('Not restricted by any role')]);
+ }
+
+ $roles = [];
+ if (! empty($restrictions) && count($restrictions) > 1) {
+ list($combinedRestrictions, $combinedLinks) = $this->createRestrictionLinks($restriction, $restrictions);
+ $roles[] = HtmlElement::create('li', null, [
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'flex-overflow']),
+ HtmlElement::create('span', [
+ 'class' => 'role',
+ 'title' => t('All roles combined')
+ ], join(' | ', array_map(function ($role) {
+ return $role->getName();
+ }, $restrictedBy))),
+ HtmlElement::create('code', ['class' => 'restriction'], $combinedRestrictions)
+ ),
+ $combinedLinks ? new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'previews']),
+ HtmlElement::create('em', null, t('Previews:')),
+ $combinedLinks
+ ) : null
+ ]);
+ }
+
+ foreach ($restrictedBy as $role) {
+ list($roleRestriction, $restrictionLinks) = $this->createRestrictionLinks(
+ $restriction,
+ [$role->getRestrictions($restriction)]
+ );
+
+ $roles[] = HtmlElement::create('li', null, [
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'flex-overflow']),
+ new Link($role->getName(), Url::fromPath('role/edit', ['role' => $role->getName()]), [
+ 'class' => 'role',
+ 'title' => $role->getName()
+ ]),
+ HtmlElement::create('code', ['class' => 'restriction'], $roleRestriction)
+ ),
+ $restrictionLinks ? new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'previews']),
+ HtmlElement::create('em', null, t('Previews:')),
+ $restrictionLinks
+ ) : null
+ ]);
+ }
+
+ if (empty($roles)) {
+ return [
+ ! empty($restrictedBy),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'restrictions']),
+ $header->setTag('div')
+ )
+ ];
+ }
+
+ return [
+ ! empty($restrictedBy),
+ new HtmlElement(
+ 'details',
+ Attributes::create([
+ 'class' => ['collapsible', 'restrictions'],
+ 'data-no-persistence' => true,
+ 'open' => getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf'
+ ]),
+ $header->addAttributes(['class' => 'collapsible-control']),
+ new HtmlElement('ul', null, ...$roles)
+ )
+ ];
+ }
+
+ protected function assemble()
+ {
+ list($permissions, $restrictions) = RoleForm::collectProvidedPrivileges();
+ list($wildcardState, $wildcardAudit) = $this->auditPermission('*');
+ list($unrestrictedState, $unrestrictedAudit) = $this->auditPermission(self::UNRESTRICTED_PERMISSION);
+
+ $this->addHtml(new HtmlElement(
+ 'li',
+ null,
+ new HtmlElement(
+ 'details',
+ Attributes::create([
+ 'class' => ['collapsible', 'privilege-section'],
+ 'open' => ($wildcardState || $unrestrictedState) && getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf'
+ ]),
+ new HtmlElement(
+ 'summary',
+ Attributes::create(['class' => [
+ 'collapsible-control', // Helps JS, improves performance a bit
+ ]]),
+ new HtmlElement('span', null, Text::create(t('Administrative Privileges'))),
+ HtmlElement::create(
+ 'span',
+ ['class' => 'audit-preview'],
+ $wildcardState || $unrestrictedState
+ ? new Icon('check-circle', ['class' => 'granted'])
+ : null
+ ),
+ new Icon('angles-down', ['class' => 'collapse-icon']),
+ new Icon('angles-left', ['class' => 'expand-icon'])
+ ),
+ new HtmlElement(
+ 'ol',
+ Attributes::create(['class' => 'privilege-list']),
+ new HtmlElement(
+ 'li',
+ null,
+ HtmlElement::create('p', ['class' => 'privilege-label'], t('Administrative Access')),
+ HtmlElement::create('div', ['class' => 'spacer']),
+ $wildcardAudit
+ ),
+ new HtmlElement(
+ 'li',
+ null,
+ HtmlElement::create('p', ['class' => 'privilege-label'], t('Unrestricted Access')),
+ HtmlElement::create('div', ['class' => 'spacer']),
+ $unrestrictedAudit
+ )
+ )
+ )
+ ));
+
+ $privilegeSources = array_unique(array_merge(array_keys($permissions), array_keys($restrictions)));
+ foreach ($privilegeSources as $source) {
+ $anythingGranted = false;
+ $anythingRefused = false;
+ $anythingRestricted = false;
+
+ $permissionList = new HtmlElement('ol', Attributes::create(['class' => 'privilege-list']));
+ foreach (isset($permissions[$source]) ? $permissions[$source] : [] as $permission => $metaData) {
+ list($permissionState, $permissionAudit) = $this->auditPermission($permission);
+ if ($permissionState !== null) {
+ if ($permissionState) {
+ $anythingGranted = true;
+ } else {
+ $anythingRefused = true;
+ }
+ }
+
+ $permissionList->addHtml(new HtmlElement(
+ 'li',
+ null,
+ HtmlElement::create(
+ 'p',
+ ['class' => 'privilege-label'],
+ isset($metaData['label'])
+ ? $metaData['label']
+ : array_map(function ($segment) {
+ return $segment[0] === '/' ? [
+ // Adds a zero-width char after each slash to help browsers break onto newlines
+ new HtmlString('/&#8203;'),
+ HtmlElement::create('span', ['class' => 'no-wrap'], substr($segment, 1))
+ ] : HtmlElement::create('em', null, $segment);
+ }, preg_split(
+ '~(/[^/]+)~',
+ $permission,
+ -1,
+ PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY
+ ))
+ ),
+ new HtmlElement('div', Attributes::create(['class' => 'spacer'])),
+ $permissionAudit
+ ));
+ }
+
+ $restrictionList = new HtmlElement('ol', Attributes::create(['class' => 'privilege-list']));
+ foreach (isset($restrictions[$source]) ? $restrictions[$source] : [] as $restriction => $metaData) {
+ list($restrictionState, $restrictionAudit) = $this->auditRestriction($restriction);
+ if ($restrictionState) {
+ $anythingRestricted = true;
+ }
+
+ $restrictionList->addHtml(new HtmlElement(
+ 'li',
+ null,
+ HtmlElement::create(
+ 'p',
+ ['class' => 'privilege-label'],
+ isset($metaData['label'])
+ ? $metaData['label']
+ : array_map(function ($segment) {
+ return $segment[0] === '/' ? [
+ // Adds a zero-width char after each slash to help browsers break onto newlines
+ new HtmlString('/&#8203;'),
+ HtmlElement::create('span', ['class' => 'no-wrap'], substr($segment, 1))
+ ] : HtmlElement::create('em', null, $segment);
+ }, preg_split(
+ '~(/[^/]+)~',
+ $restriction,
+ -1,
+ PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY
+ ))
+ ),
+ new HtmlElement('div', Attributes::create(['class' => 'spacer'])),
+ $restrictionAudit
+ ));
+ }
+
+ if ($source === 'application') {
+ $label = 'Icinga Web 2';
+ } else {
+ $label = [$source, ' ', HtmlElement::create('em', null, t('Module'))];
+ }
+
+ $this->addHtml(new HtmlElement(
+ 'li',
+ null,
+ HtmlElement::create('details', [
+ 'class' => ['collapsible', 'privilege-section'],
+ 'open' => ($anythingGranted || $anythingRefused || $anythingRestricted)
+ && getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf'
+ ], [
+ new HtmlElement(
+ 'summary',
+ Attributes::create(['class' => [
+ 'collapsible-control', // Helps JS, improves performance a bit
+ ]]),
+ HtmlElement::create('span', null, $label),
+ HtmlElement::create('span', ['class' => 'audit-preview'], [
+ $anythingGranted ? new Icon('check-circle', ['class' => 'granted']) : null,
+ $anythingRefused ? new Icon('times-circle', ['class' => 'refused']) : null,
+ $anythingRestricted ? new Icon('filter', ['class' => 'restricted']) : null
+ ]),
+ new Icon('angles-down', ['class' => 'collapse-icon']),
+ new Icon('angles-left', ['class' => 'expand-icon'])
+ ),
+ $permissionList->isEmpty() ? null : [
+ HtmlElement::create('h4', null, t('Permissions')),
+ $permissionList
+ ],
+ $restrictionList->isEmpty() ? null : [
+ HtmlElement::create('h4', null, t('Restrictions')),
+ $restrictionList
+ ]
+ ])
+ ));
+ }
+ }
+
+ private function collectRestrictions(Role $role, $restrictionName)
+ {
+ do {
+ $restriction = $role->getRestrictions($restrictionName);
+ if ($restriction) {
+ yield $role => $restriction;
+ }
+ } while (($role = $role->getParent()) !== null);
+ }
+
+ private function createRestrictionLinks($restrictionName, array $restrictions)
+ {
+ // TODO: Remove this hardcoded mess. Do this based on the restriction's meta data
+ switch ($restrictionName) {
+ case 'icingadb/filter/objects':
+ $filterString = join('|', $restrictions);
+ $list = new HtmlElement(
+ 'ul',
+ Attributes::create(['class' => 'links']),
+ new HtmlElement('li', null, new Link(
+ 'icingadb/hosts',
+ Url::fromPath('icingadb/hosts')->setQueryString($filterString)
+ )),
+ new HtmlElement('li', null, new Link(
+ 'icingadb/services',
+ Url::fromPath('icingadb/services')->setQueryString($filterString)
+ )),
+ new HtmlElement('li', null, new Link(
+ 'icingadb/hostgroups',
+ Url::fromPath('icingadb/hostgroups')->setQueryString($filterString)
+ )),
+ new HtmlElement('li', null, new Link(
+ 'icingadb/servicegroups',
+ Url::fromPath('icingadb/servicegroups')->setQueryString($filterString)
+ ))
+ );
+
+ break;
+ case 'icingadb/filter/hosts':
+ $filterString = join('|', $restrictions);
+ $list = new HtmlElement(
+ 'ul',
+ Attributes::create(['class' => 'links']),
+ new HtmlElement('li', null, new Link(
+ 'icingadb/hosts',
+ Url::fromPath('icingadb/hosts')->setQueryString($filterString)
+ )),
+ new HtmlElement('li', null, new Link(
+ 'icingadb/services',
+ Url::fromPath('icingadb/services')->setQueryString($filterString)
+ ))
+ );
+
+ break;
+ case 'icingadb/filter/services':
+ $filterString = join('|', $restrictions);
+ $list = new HtmlElement(
+ 'ul',
+ Attributes::create(['class' => 'links']),
+ new HtmlElement('li', null, new Link(
+ 'icingadb/services',
+ Url::fromPath('icingadb/services')->setQueryString($filterString)
+ ))
+ );
+
+ break;
+ case 'monitoring/filter/objects':
+ $filterString = join('|', $restrictions);
+ $list = new HtmlElement(
+ 'ul',
+ Attributes::create(['class' => 'links']),
+ new HtmlElement('li', null, new Link(
+ 'monitoring/list/hosts',
+ Url::fromPath('monitoring/list/hosts')->setQueryString($filterString)
+ )),
+ new HtmlElement('li', null, new Link(
+ 'monitoring/list/services',
+ Url::fromPath('monitoring/list/services')->setQueryString($filterString)
+ )),
+ new HtmlElement('li', null, new Link(
+ 'monitoring/list/hostgroups',
+ Url::fromPath('monitoring/list/hostgroups')->setQueryString($filterString)
+ )),
+ new HtmlElement('li', null, new Link(
+ 'monitoring/list/servicegroups',
+ Url::fromPath('monitoring/list/servicegroups')->setQueryString($filterString)
+ ))
+ );
+
+ break;
+ case 'application/share/users':
+ $filter = Filter::any();
+ foreach ($restrictions as $roleRestriction) {
+ $userNames = StringHelper::trimSplit($roleRestriction);
+ foreach ($userNames as $userName) {
+ $filter->add(Filter::equal('user_name', $userName));
+ }
+ }
+
+ $filterString = QueryString::render($filter);
+ $list = new HtmlElement(
+ 'ul',
+ Attributes::create(['class' => 'links']),
+ new HtmlElement('li', null, new Link(
+ 'user/list',
+ Url::fromPath('user/list')->setQueryString($filterString)
+ ))
+ );
+
+ break;
+ case 'application/share/groups':
+ $filter = Filter::any();
+ foreach ($restrictions as $roleRestriction) {
+ $groupNames = StringHelper::trimSplit($roleRestriction);
+ foreach ($groupNames as $groupName) {
+ $filter->add(Filter::equal('group_name', $groupName));
+ }
+ }
+
+ $filterString = QueryString::render($filter);
+ $list = new HtmlElement(
+ 'ul',
+ Attributes::create(['class' => 'links']),
+ new HtmlElement('li', null, new Link(
+ 'group/list',
+ Url::fromPath('group/list')->setQueryString($filterString)
+ ))
+ );
+
+ break;
+ default:
+ $filterString = join(', ', $restrictions);
+ $list = null;
+ }
+
+ return [$filterString, $list];
+ }
+}
diff --git a/library/Icinga/Web/View/helpers/format.php b/library/Icinga/Web/View/helpers/format.php
new file mode 100644
index 0000000..4008583
--- /dev/null
+++ b/library/Icinga/Web/View/helpers/format.php
@@ -0,0 +1,72 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\View;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Util\Format;
+
+$this->addHelperFunction('format', function () {
+ return Format::getInstance();
+});
+
+$this->addHelperFunction('formatDate', function ($date) {
+ if (! $date) {
+ return '';
+ }
+ return DateFormatter::formatDate($date);
+});
+
+$this->addHelperFunction('formatDateTime', function ($dateTime) {
+ if (! $dateTime) {
+ return '';
+ }
+ return DateFormatter::formatDateTime($dateTime);
+});
+
+$this->addHelperFunction('formatDuration', function ($seconds) {
+ if (! $seconds) {
+ return '';
+ }
+ return DateFormatter::formatDuration($seconds);
+});
+
+$this->addHelperFunction('formatTime', function ($time) {
+ if (! $time) {
+ return '';
+ }
+ return DateFormatter::formatTime($time);
+});
+
+$this->addHelperFunction('timeAgo', function ($time, $timeOnly = false, $requireTime = false) {
+ if (! $time) {
+ return '';
+ }
+ return sprintf(
+ '<span class="relative-time time-ago" title="%s">%s</span>',
+ DateFormatter::formatDateTime($time),
+ DateFormatter::timeAgo($time, $timeOnly, $requireTime)
+ );
+});
+
+$this->addHelperFunction('timeSince', function ($time, $timeOnly = false, $requireTime = false) {
+ if (! $time) {
+ return '';
+ }
+ return sprintf(
+ '<span class="relative-time time-since" title="%s">%s</span>',
+ DateFormatter::formatDateTime($time),
+ DateFormatter::timeSince($time, $timeOnly, $requireTime)
+ );
+});
+
+$this->addHelperFunction('timeUntil', function ($time, $timeOnly = false, $requireTime = false) {
+ if (! $time) {
+ return '';
+ }
+ return sprintf(
+ '<span class="relative-time time-until" title="%s">%s</span>',
+ DateFormatter::formatDateTime($time),
+ DateFormatter::timeUntil($time, $timeOnly, $requireTime)
+ );
+});
diff --git a/library/Icinga/Web/View/helpers/generic.php b/library/Icinga/Web/View/helpers/generic.php
new file mode 100644
index 0000000..bfd3f86
--- /dev/null
+++ b/library/Icinga/Web/View/helpers/generic.php
@@ -0,0 +1,15 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\View;
+
+use Icinga\Authentication\Auth;
+use Icinga\Web\Widget;
+
+$this->addHelperFunction('auth', function () {
+ return Auth::getInstance();
+});
+
+$this->addHelperFunction('widget', function ($name, $options = null) {
+ return Widget::create($name, $options);
+});
diff --git a/library/Icinga/Web/View/helpers/string.php b/library/Icinga/Web/View/helpers/string.php
new file mode 100644
index 0000000..b3f667b
--- /dev/null
+++ b/library/Icinga/Web/View/helpers/string.php
@@ -0,0 +1,36 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\View;
+
+use Icinga\Util\StringHelper;
+use Icinga\Web\Helper\Markdown;
+
+$this->addHelperFunction('ellipsis', function ($string, $maxLength, $ellipsis = '...') {
+ return StringHelper::ellipsis($string, $maxLength, $ellipsis);
+});
+
+$this->addHelperFunction('nl2br', function ($string) {
+ return nl2br(str_replace(array('\r\n', '\r', '\n'), '<br>', $string), false);
+});
+
+$this->addHelperFunction('markdown', function ($content, $containerAttribs = null) {
+ if (! isset($containerAttribs['class'])) {
+ $containerAttribs['class'] = 'markdown';
+ } else {
+ $containerAttribs['class'] .= ' markdown';
+ }
+
+ return '<section' . $this->propertiesToString($containerAttribs) . '>' . Markdown::text($content) . '</section>';
+});
+
+$this->addHelperFunction('markdownLine', function ($content, $containerAttribs = null) {
+ if (! isset($containerAttribs['class'])) {
+ $containerAttribs['class'] = 'markdown inline';
+ } else {
+ $containerAttribs['class'] .= ' markdown inline';
+ }
+
+ return '<section' . $this->propertiesToString($containerAttribs) . '>' .
+ Markdown::line($content) . '</section>';
+});
diff --git a/library/Icinga/Web/View/helpers/url.php b/library/Icinga/Web/View/helpers/url.php
new file mode 100644
index 0000000..277c237
--- /dev/null
+++ b/library/Icinga/Web/View/helpers/url.php
@@ -0,0 +1,158 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\View;
+
+use Icinga\Web\Url;
+use Icinga\Exception\ProgrammingError;
+
+$view = $this;
+
+$this->addHelperFunction('href', function ($path = null, $params = null) use ($view) {
+ return $view->url($path, $params);
+});
+
+$this->addHelperFunction('url', function ($path = null, $params = null) {
+ if ($path === null) {
+ $url = Url::fromRequest();
+ } elseif ($path instanceof Url) {
+ $url = $path;
+ } else {
+ $url = Url::fromPath($path);
+ }
+
+ if ($params !== null) {
+ if ($url === $path) {
+ $url = clone $url;
+ }
+
+ $url->overwriteParams($params);
+ }
+
+ return $url;
+});
+
+$this->addHelperFunction(
+ 'qlink',
+ function ($title, $url, $params = null, $properties = null, $escape = true) use ($view) {
+ $icon = '';
+ if ($properties) {
+ if (array_key_exists('title', $properties) && !array_key_exists('aria-label', $properties)) {
+ $properties['aria-label'] = $properties['title'];
+ }
+
+ if (array_key_exists('icon', $properties)) {
+ $icon = $view->icon($properties['icon']);
+ unset($properties['icon']);
+ }
+
+ if (array_key_exists('img', $properties)) {
+ $icon = $view->img($properties['img']);
+ unset($properties['img']);
+ }
+ }
+
+ return sprintf(
+ '<a href="%s"%s>%s</a>',
+ $view->url($url, $params),
+ $view->propertiesToString($properties),
+ $icon . ($escape ? $view->escape($title) : $title)
+ );
+ }
+);
+
+$this->addHelperFunction('img', function ($url, $params = null, array $properties = array()) use ($view) {
+ if (! array_key_exists('alt', $properties)) {
+ $properties['alt'] = '';
+ }
+
+ $ariaHidden = array_key_exists('aria-hidden', $properties) ? $properties['aria-hidden'] : null;
+ if (array_key_exists('title', $properties)) {
+ if (! array_key_exists('aria-label', $properties) && $ariaHidden !== 'true') {
+ $properties['aria-label'] = $properties['title'];
+ }
+ } elseif ($ariaHidden === null) {
+ $properties['aria-hidden'] = 'true';
+ }
+
+ return sprintf(
+ '<img src="%s"%s />',
+ $view->escape($view->url($url, $params)->getAbsoluteUrl()),
+ $view->propertiesToString($properties)
+ );
+});
+
+$this->addHelperFunction('icon', function ($img, $title = null, array $properties = array()) use ($view) {
+ if (strpos($img, '.') !== false) {
+ if (array_key_exists('class', $properties)) {
+ $properties['class'] .= ' icon';
+ } else {
+ $properties['class'] = 'icon';
+ }
+ if (strpos($img, '/') === false) {
+ return $view->img('img/icons/' . $img, null, $properties);
+ } else {
+ return $view->img($img, null, $properties);
+ }
+ }
+
+ $ariaHidden = array_key_exists('aria-hidden', $properties) ? $properties['aria-hidden'] : null;
+ if ($title !== null) {
+ $properties['role'] = 'img';
+ $properties['title'] = $title;
+
+ if (! array_key_exists('aria-label', $properties) && $ariaHidden !== 'true') {
+ $properties['aria-label'] = $title;
+ }
+ } elseif ($ariaHidden === null) {
+ $properties['aria-hidden'] = 'true';
+ }
+
+ if (isset($properties['class'])) {
+ $properties['class'] .= ' icon-' . $img;
+ } else {
+ $properties['class'] = 'icon-' . $img;
+ }
+
+ return sprintf('<i %s></i>', $view->propertiesToString($properties));
+});
+
+$this->addHelperFunction('propertiesToString', function ($properties) use ($view) {
+ if (empty($properties)) {
+ return '';
+ }
+ $attributes = array();
+
+ foreach ($properties as $key => $val) {
+ if ($key === 'style' && is_array($val)) {
+ if (empty($val)) {
+ continue;
+ }
+ $parts = array();
+ foreach ($val as $k => $v) {
+ $parts[] = "$k: $v";
+ }
+ $val = implode('; ', $parts);
+ continue;
+ }
+
+ $attributes[] = $view->attributeToString($key, $val);
+ }
+ return ' ' . implode(' ', $attributes);
+});
+
+$this->addHelperFunction('attributeToString', function ($key, $value) use ($view) {
+ // TODO: Doublecheck this!
+ if (! preg_match('~^[a-zA-Z0-9-]+$~', $key)) {
+ throw new ProgrammingError(
+ 'Trying to set an invalid HTML attribute name: %s',
+ $key
+ );
+ }
+
+ return sprintf(
+ '%s="%s"',
+ $key,
+ $view->escape($value)
+ );
+});
diff --git a/library/Icinga/Web/Widget.php b/library/Icinga/Web/Widget.php
new file mode 100644
index 0000000..48ae7bd
--- /dev/null
+++ b/library/Icinga/Web/Widget.php
@@ -0,0 +1,49 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Widget\AbstractWidget;
+
+/**
+ * Web widgets make things easier for you!
+ *
+ * This class provides nothing but a static factory method for widget creation.
+ * Usually it will not be used directly as there are widget()-helpers available
+ * in your action controllers and view scripts.
+ *
+ * Usage example:
+ * <code>
+ * $tabs = Widget::create('tabs');
+ * </code>
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+class Widget
+{
+ /**
+ * Create a new widget
+ *
+ * @param string $name Widget name
+ * @param array $options Widget constructor options
+ *
+ * @return AbstractWidget
+ */
+ public static function create($name, $options = array(), $module_name = null)
+ {
+ $class = 'Icinga\\Web\\Widget\\' . ucfirst($name);
+
+ if (! class_exists($class)) {
+ throw new ProgrammingError(
+ 'There is no such widget: %s',
+ $name
+ );
+ }
+
+ $widget = new $class($options, $module_name);
+ return $widget;
+ }
+}
diff --git a/library/Icinga/Web/Widget/AbstractWidget.php b/library/Icinga/Web/Widget/AbstractWidget.php
new file mode 100644
index 0000000..1090548
--- /dev/null
+++ b/library/Icinga/Web/Widget/AbstractWidget.php
@@ -0,0 +1,121 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Application\Icinga;
+use Exception;
+use Zend_View_Abstract;
+
+/**
+ * Web widgets MUST extend this class
+ *
+ * AbstractWidget implements getters and setters for widget options stored in
+ * the protected options array. If you want to allow options for your own
+ * widget, you have to set a default value (may be null) for each single option
+ * in this array.
+ *
+ * Please have a look at the available widgets in this folder to get a better
+ * idea on what they should look like.
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+abstract class AbstractWidget
+{
+ /**
+ * If you are going to access the current view with the view() function,
+ * its instance is stored here for performance reasons.
+ *
+ * @var Zend_View_Abstract
+ */
+ protected static $view;
+
+ // TODO: Should we kick this?
+ protected $properties = array();
+
+ /**
+ * Getter for widget properties
+ *
+ * @param string $key The option you're interested in
+ *
+ * @throws ProgrammingError for unknown property name
+ *
+ * @return mixed
+ */
+ public function __get($key)
+ {
+ if (array_key_exists($key, $this->properties)) {
+ return $this->properties[$key];
+ }
+
+ throw new ProgrammingError(
+ 'Trying to get invalid "%s" property for %s',
+ $key,
+ get_class($this)
+ );
+ }
+
+ /**
+ * Setter for widget properties
+ *
+ * @param string $key The option you want to set
+ * @param string $val The new value going to be assigned to this option
+ *
+ * @throws ProgrammingError for unknown property name
+ *
+ * @return mixed
+ */
+ public function __set($key, $val)
+ {
+ if (array_key_exists($key, $this->properties)) {
+ $this->properties[$key] = $val;
+ return;
+ }
+
+ throw new ProgrammingError(
+ 'Trying to set invalid "%s" property in %s. Allowed are: %s',
+ $key,
+ get_class($this),
+ empty($this->properties)
+ ? 'none'
+ : implode(', ', array_keys($this->properties))
+ );
+ }
+
+ abstract public function render();
+
+ /**
+ * Access the current view
+ *
+ * Will instantiate a new one if none exists
+ * // TODO: App->getView
+ *
+ * @return Zend_View_Abstract
+ */
+ protected function view()
+ {
+ if (self::$view === null) {
+ self::$view = Icinga::app()->getViewRenderer()->view;
+ }
+
+ return self::$view;
+ }
+
+ /**
+ * Cast this widget to a string. Will call your render() function
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ $html = $this->render();
+ } catch (Exception $e) {
+ return htmlspecialchars($e->getMessage());
+ }
+ return (string) $html;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Announcements.php b/library/Icinga/Web/Widget/Announcements.php
new file mode 100644
index 0000000..e0fac77
--- /dev/null
+++ b/library/Icinga/Web/Widget/Announcements.php
@@ -0,0 +1,55 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Icinga;
+use Icinga\Data\Filter\Filter;
+use Icinga\Forms\Announcement\AcknowledgeAnnouncementForm;
+use Icinga\Web\Announcement\AnnouncementCookie;
+use Icinga\Web\Announcement\AnnouncementIniRepository;
+use Icinga\Web\Helper\Markdown;
+
+/**
+ * Render announcements
+ */
+class Announcements extends AbstractWidget
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ $repo = new AnnouncementIniRepository();
+ $etag = $repo->getEtag();
+ $cookie = new AnnouncementCookie();
+ if ($cookie->getEtag() !== $etag) {
+ $cookie->setEtag($etag);
+ $cookie->setNextActive($repo->findNextActive());
+ Icinga::app()->getResponse()->setCookie($cookie);
+ }
+ $acked = array();
+ foreach ($cookie->getAcknowledged() as $hash) {
+ $acked[] = Filter::expression('hash', '!=', $hash);
+ }
+ $acked = Filter::matchAll($acked);
+ $announcements = $repo->findActive();
+ $announcements->applyFilter($acked);
+ if ($announcements->hasResult()) {
+ $html = '<ul role="alert">';
+ foreach ($announcements as $announcement) {
+ $ackForm = new AcknowledgeAnnouncementForm();
+ $ackForm->populate(array('hash' => $announcement->hash));
+ $html .= '<li><div class="message">'
+ . Markdown::text($announcement->message)
+ . '</div>'
+ . $ackForm
+ . '</li>';
+ }
+ $html .= '</ul>';
+ return $html;
+ }
+ // Force container update on XHR
+ return '<div hidden></div>';
+ }
+}
diff --git a/library/Icinga/Web/Widget/ApplicationStateMessages.php b/library/Icinga/Web/Widget/ApplicationStateMessages.php
new file mode 100644
index 0000000..99d3bb2
--- /dev/null
+++ b/library/Icinga/Web/Widget/ApplicationStateMessages.php
@@ -0,0 +1,74 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Config;
+use Icinga\Application\Hook\ApplicationStateHook;
+use Icinga\Authentication\Auth;
+use Icinga\Forms\AcknowledgeApplicationStateMessageForm;
+use Icinga\Web\ApplicationStateCookie;
+use Icinga\Web\Helper\Markdown;
+
+/**
+ * Render application state messages
+ */
+class ApplicationStateMessages extends AbstractWidget
+{
+ protected function getMessages()
+ {
+ $cookie = new ApplicationStateCookie();
+
+ $acked = array_flip($cookie->getAcknowledgedMessages());
+ $messages = ApplicationStateHook::getAllMessages();
+
+ $active = array_diff_key($messages, $acked);
+
+ return $active;
+ }
+
+ public function render()
+ {
+ $enabled = Auth::getInstance()
+ ->getUser()
+ ->getPreferences()
+ ->getValue('icingaweb', 'show_application_state_messages', 'system');
+
+ if ($enabled === 'system') {
+ $enabled = Config::app()->get('global', 'show_application_state_messages', true);
+ }
+
+ if (! (bool) $enabled) {
+ return '<div hidden></div>';
+ }
+
+ $active = $this->getMessages();
+
+ if (empty($active)) {
+ // Force container update on XHR
+ return '<div hidden></div>';
+ }
+
+ $html = '<div>';
+
+ reset($active);
+
+ $id = key($active);
+ $spec = current($active);
+ $message = array_pop($spec); // We don't use state and timestamp here
+
+
+ $ackForm = new AcknowledgeApplicationStateMessageForm();
+ $ackForm->populate(['id' => $id]);
+
+ $html .= '<section class="markdown">';
+ $html .= Markdown::text($message);
+ $html .= '</section>';
+
+ $html .= $ackForm;
+
+ $html .= '</div>';
+
+ return $html;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php
new file mode 100644
index 0000000..b7b50d0
--- /dev/null
+++ b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php
@@ -0,0 +1,400 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Chart;
+
+use DateInterval;
+use DateTime;
+use Icinga\Util\Color;
+use Icinga\Util\Csp;
+use Icinga\Web\Widget\AbstractWidget;
+use ipl\Web\Style;
+
+/**
+ * Display a colored grid that visualizes a set of values for each day
+ * on a given time-frame.
+ */
+class HistoryColorGrid extends AbstractWidget
+{
+ const CAL_GROW_INTO_PAST = 'past';
+ const CAL_GROW_INTO_PRESENT = 'present';
+
+ const ORIENTATION_VERTICAL = 'vertical';
+ const ORIENTATION_HORIZONTAL = 'horizontal';
+
+ public $weekFlow = self::CAL_GROW_INTO_PAST;
+ public $orientation = self::ORIENTATION_VERTICAL;
+ public $weekStartMonday = true;
+
+ private $maxValue = 1;
+
+ private $start = null;
+ private $end = null;
+ private $data = array();
+ private $color;
+ public $opacity = 1.0;
+
+ /** @var array<string, array<string, string>> History grid css rulesets */
+ protected $rulesets = [];
+
+ public function __construct($color = '#51e551', $start = null, $end = null)
+ {
+ $this->setColor($color);
+ if (isset($start)) {
+ $this->start = $this->tsToDateStr($start);
+ }
+ if (isset($end)) {
+ $this->end = $this->tsToDateStr($end);
+ }
+ }
+
+ /**
+ * Set the displayed data-set
+ *
+ * @param $events array The history events to display as an array of arrays:
+ * value: The value to display
+ * caption: The caption on mouse-over
+ * url: The url to open on click.
+ */
+ public function setData(array $events)
+ {
+ $this->data = $events;
+ $start = time();
+ $end = time();
+ foreach ($this->data as $entry) {
+ $entry['value'] = intval($entry['value']);
+ }
+ foreach ($this->data as $date => $entry) {
+ $time = strtotime($date);
+ if ($entry['value'] > $this->maxValue) {
+ $this->maxValue = $entry['value'];
+ }
+ if ($time > $end) {
+ $end = $time;
+ }
+ if ($time < $start) {
+ $start = $time;
+ }
+ }
+ if (!isset($this->start)) {
+ $this->start = $this->tsToDateStr($start);
+ }
+ if (!isset($this->end)) {
+ $this->end = $this->tsToDateStr($end);
+ }
+ }
+
+ /**
+ * Set the used color.
+ *
+ * @param $color
+ */
+ public function setColor($color)
+ {
+ $this->color = $color;
+ }
+
+ /**
+ * Set the used opacity
+ *
+ * @param $opacity
+ */
+ public function setOpacity($opacity)
+ {
+ $this->opacity = $opacity;
+ }
+
+ /**
+ * Calculate the color to display for the given value.
+ *
+ * @param $value integer
+ *
+ * @return string The color-string to use for this entry.
+ */
+ private function calculateColor($value)
+ {
+ $saturation = $value / $this->maxValue;
+ return Color::changeSaturation($this->color, $saturation);
+ }
+
+ /**
+ * Render the html to display the given $day
+ *
+ * @param $day string The day to display YYYY-MM-DD
+ *
+ * @return string The rendered html
+ */
+ private function renderDay($day)
+ {
+ if (array_key_exists($day, $this->data) && $this->data[$day]['value'] > 0) {
+ $entry = $this->data[$day];
+ $this->rulesets['.grid-day-with-entry-' . $entry['value']] = [
+ 'background-color' => $this->calculateColor($entry['value']),
+ 'opacity' => $this->opacity
+ ];
+
+ return '<a class="grid-day-with-entry-'
+ . $entry['value']
+ . '" '
+ . 'aria-label="' . $entry['caption']
+ . '" '
+ . 'title="' . $entry['caption']
+ . '" '
+ . 'href="' . $entry['url']
+ . '" '
+ . '"></a>';
+ } else {
+ if (! isset($this->rulesets['.grid-day-no-entry'])) {
+ $this->rulesets['.grid-day-no-entry'] = [
+ 'background-color' => $this->calculateColor(0),
+ 'opacity' => $this->opacity
+ ];
+ }
+
+ return '<span class="grid-day-no-entry"' . ' title="No entries for ' . $day . '"></span>';
+ }
+ }
+
+ /**
+ * Render the grid with an horizontal alignment.
+ *
+ * @param array $grid The values returned from the createGrid function
+ *
+ * @return string The rendered html
+ */
+ private function renderHorizontal($grid)
+ {
+ $weeks = $grid['weeks'];
+ $months = $grid['months'];
+ $years = $grid['years'];
+ $html = '<table class="historycolorgrid">';
+ $html .= '<tr><th></th>';
+ $old = -1;
+ foreach ($months as $week => $month) {
+ if ($old !== $month) {
+ $old = $month;
+ $txt = $this->monthName($month, $years[$week]);
+ } else {
+ $txt = '';
+ }
+ $html .= '<th>' . $txt . '</th>';
+ }
+ $html .= '</tr>';
+ for ($i = 0; $i < 7; $i++) {
+ $html .= $this->renderWeekdayHorizontal($i, $weeks);
+ }
+ $html .= '</table>';
+ return $html;
+ }
+
+ /**
+ * @param $grid
+ *
+ * @return string
+ */
+ private function renderVertical($grid)
+ {
+ $years = $grid['years'];
+ $weeks = $grid['weeks'];
+ $months = $grid['months'];
+ $html = '<table class="historycolorgrid">';
+ $html .= '<tr>';
+ for ($i = 0; $i < 7; $i++) {
+ $html .= '<th>' . $this->weekdayName($this->weekStartMonday ? $i + 1 : $i) . "</th>";
+ }
+ $html .= '</tr>';
+ $old = -1;
+ foreach ($weeks as $index => $week) {
+ for ($i = 0; $i < 7; $i++) {
+ if (array_key_exists($i, $week)) {
+ $html .= '<td>' . $this->renderDay($week[$i]) . '</td>';
+ } else {
+ $html .= '<td></td>';
+ }
+ }
+ if ($old !== $months[$index]) {
+ $old = $months[$index];
+ $txt = $this->monthName($old, $years[$index]);
+ } else {
+ $txt = '';
+ }
+ $html .= '<td class="weekday">' . $txt . '</td></tr>';
+ }
+ $html .= '</table>';
+ return $html;
+ }
+
+ /**
+ * Render the row for the given weekday.
+ *
+ * @param integer $weekday The day to render (0-6)
+ * @param array $weeks The weeks
+ *
+ * @return string The formatted table-row
+ */
+ private function renderWeekdayHorizontal($weekday, &$weeks)
+ {
+ $html = '<tr><td class="weekday">'
+ . $this->weekdayName($this->weekStartMonday ? $weekday + 1 : $weekday)
+ . '</td>';
+ foreach ($weeks as $week) {
+ if (array_key_exists($weekday, $week)) {
+ $html .= '<td>' . $this->renderDay($week[$weekday]) . '</td>';
+ } else {
+ $html .= '<td></td>';
+ }
+ }
+ $html .= '</tr>';
+ return $html;
+ }
+
+
+
+ /**
+ * @return array
+ */
+ private function createGrid()
+ {
+ $weeks = array(array());
+ $week = 0;
+ $months = array();
+ $years = array();
+ $start = strtotime($this->start);
+ $year = intval(date('Y', $start));
+ $month = intval(date('n', $start));
+ $day = intval(date('j', $start));
+ $weekday = intval(date('w', $start));
+ if ($this->weekStartMonday) {
+ // 0 => monday, 6 => sunday
+ $weekday = $weekday === 0 ? 6 : $weekday - 1;
+ }
+
+ $date = $this->toDateStr($day, $month, $year);
+ $weeks[0][$weekday] = $date;
+ $years[0] = $year;
+ $months[0] = $month;
+ while ($date !== $this->end) {
+ $day++;
+ $weekday++;
+ if ($weekday > 6) {
+ $weekday = 0;
+ $weeks[] = array();
+ // PRESENT => The last day of week determines the month
+ if ($this->weekFlow === self::CAL_GROW_INTO_PRESENT) {
+ $months[$week] = $month;
+ $years[$week] = $year;
+ }
+ $week++;
+ }
+ if ($day > date('t', mktime(0, 0, 0, $month, 1, $year))) {
+ $month++;
+ if ($month > 12) {
+ $year++;
+ $month = 1;
+ }
+ $day = 1;
+ }
+ if ($weekday === 0) {
+ // PAST => The first day of each week determines the month
+ if ($this->weekFlow === self::CAL_GROW_INTO_PAST) {
+ $months[$week] = $month;
+ $years[$week] = $year;
+ }
+ }
+ $date = $this->toDateStr($day, $month, $year);
+ $weeks[$week][$weekday] = $date;
+ };
+ $years[$week] = $year;
+ $months[$week] = $month;
+ if ($this->weekFlow == self::CAL_GROW_INTO_PAST) {
+ return array(
+ 'weeks' => array_reverse($weeks),
+ 'months' => array_reverse($months),
+ 'years' => array_reverse($years)
+ );
+ }
+ return array(
+ 'weeks' => $weeks,
+ 'months' => $months,
+ 'years' => $years
+ );
+ }
+
+ /**
+ * Get the localized month-name for the given month
+ *
+ * @param integer $month The month-number
+ *
+ * @return string The
+ */
+ private function monthName($month, $year)
+ {
+ // TODO: find a way to render years without messing up the layout
+ $dt = new DateTime($year . '-' . $month . '-01');
+ return $dt->format('M');
+ }
+
+ /**
+ * @param $weekday
+ *
+ * @return string
+ */
+ private function weekdayName($weekday)
+ {
+ $sun = new DateTime('last Sunday');
+ $interval = new DateInterval('P' . $weekday . 'D');
+ $sun->add($interval);
+ return substr($sun->format('D'), 0, 2);
+ }
+
+ /**
+ *
+ *
+ * @param $timestamp
+ *
+ * @return bool|string
+ */
+ private function tsToDateStr($timestamp)
+ {
+ return date('Y-m-d', $timestamp);
+ }
+
+ /**
+ * @param $day
+ * @param $mon
+ * @param $year
+ *
+ * @return string
+ */
+ private function toDateStr($day, $mon, $year)
+ {
+ $day = $day > 9 ? (string)$day : '0' . (string)$day;
+ $mon = $mon > 9 ? (string)$mon : '0' . (string)$mon;
+ return $year . '-' . $mon . '-' . $day;
+ }
+
+ /**
+ * @return string
+ */
+ public function render()
+ {
+ if (empty($this->data)) {
+ return '<div>No entries</div>';
+ }
+ $grid = $this->createGrid();
+ if ($this->orientation === self::ORIENTATION_HORIZONTAL) {
+ $html = $this->renderHorizontal($grid);
+ } else {
+ $html = $this->renderVertical($grid);
+ }
+
+ $historyGridStyle = new Style();
+ $historyGridStyle->setNonce(Csp::getStyleNonce());
+
+ foreach ($this->rulesets as $selector => $properties) {
+ $historyGridStyle->add($selector, $properties);
+ }
+
+ return $html . $historyGridStyle;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Chart/InlinePie.php b/library/Icinga/Web/Widget/Chart/InlinePie.php
new file mode 100644
index 0000000..21b4ca4
--- /dev/null
+++ b/library/Icinga/Web/Widget/Chart/InlinePie.php
@@ -0,0 +1,257 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Chart;
+
+use Icinga\Chart\PieChart;
+use Icinga\Module\Monitoring\Plugin\PerfdataSet;
+use Icinga\Util\StringHelper;
+use Icinga\Web\Widget\AbstractWidget;
+use Icinga\Web\Url;
+use Icinga\Util\Format;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use stdClass;
+
+/**
+ * A SVG-PieChart intended to be displayed as a small icon next to labels, to offer a better visualization of the
+ * shown data
+ *
+ * NOTE: When InlinePies are shown in a dynamically loaded view, like the side-bar or in the dashboard, the SVGs will
+ * be replaced with a jQuery-Sparkline to save resources @see loader.js
+ *
+ * @package Icinga\Web\Widget\Chart
+ */
+class InlinePie extends AbstractWidget
+{
+ const NUMBER_FORMAT_NONE = 'none';
+ const NUMBER_FORMAT_TIME = 'time';
+ const NUMBER_FORMAT_BYTES = 'bytes';
+ const NUMBER_FORMAT_RATIO = 'ratio';
+
+ public static $colorsHostStates = array(
+ '#44bb77', // up
+ '#ff99aa', // down
+ '#cc77ff', // unreachable
+ '#77aaff' // pending
+ );
+
+ public static $colorsHostStatesHandledUnhandled = array(
+ '#44bb77', // up
+ '#44bb77',
+ '#ff99aa', // down
+ '#ff5566',
+ '#cc77ff', // unreachable
+ '#aa44ff',
+ '#77aaff', // pending
+ '#77aaff'
+ );
+
+ public static $colorsServiceStates = array(
+ '#44bb77', // Ok
+ '#ffaa44', // Warning
+ '#ff99aa', // Critical
+ '#aa44ff', // Unknown
+ '#77aaff' // Pending
+ );
+
+ public static $colorsServiceStatesHandleUnhandled = array(
+ '#44bb77', // Ok
+ '#44bb77',
+ '#ffaa44', // Warning
+ '#ffcc66',
+ '#ff99aa', // Critical
+ '#ff5566',
+ '#cc77ff', // Unknown
+ '#aa44ff',
+ '#77aaff', // Pending
+ '#77aaff'
+ );
+
+ /**
+ * The template string used for rendering this widget
+ *
+ * @var string
+ */
+ private $template = '<div class="inline-pie {class}">{svg}</div>';
+
+ /**
+ * The colors used to display the slices of this pie-chart.
+ *
+ * @var array
+ */
+ private $colors = array('#049BAF', '#ffaa44', '#ff5566', '#ddccdd');
+
+ /**
+ * The title of the chart
+ *
+ * @var string
+ */
+ private $title;
+
+ /**
+ * @var int
+ */
+ private $size = 16;
+
+ /**
+ * The data displayed by the pie-chart
+ *
+ * @var array
+ */
+ private $data;
+
+ /**
+ * @var
+ */
+ private $class = '';
+
+ /**
+ * Set the data to be displayed.
+ *
+ * @param $data array
+ *
+ * @return $this
+ */
+ public function setData(array $data)
+ {
+ $this->data = $data;
+
+ return $this;
+ }
+
+ /**
+ * Set the size of the inline pie
+ *
+ * @param int $size Sets both, the height and width
+ *
+ * @return $this
+ */
+ public function setSize($size = null)
+ {
+ $this->size = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the class to define the
+ *
+ * @param $class
+ *
+ * @return $this
+ */
+ public function setSparklineClass($class)
+ {
+ $this->class = $class;
+
+ return $this;
+ }
+
+ /**
+ * Set the colors used by the slices of the pie chart.
+ *
+ * @param array $colors
+ *
+ * @return $this
+ */
+ public function setColors(array $colors = null)
+ {
+ $this->colors = $colors;
+
+ return $this;
+ }
+
+ /**
+ * Set the title of the displayed Data
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $this->view()->escape($title);
+
+ return $this;
+ }
+
+ /**
+ * Create a new InlinePie
+ *
+ * @param array $data The data displayed by the slices
+ * @param string $title The title of this Pie
+ * @param array $colors An array of RGB-Color values to use
+ */
+ public function __construct(array $data, $title, $colors = null)
+ {
+ $this->setTitle($title);
+
+ if (array_key_exists('data', $data)) {
+ $this->data = $data['data'];
+ if (array_key_exists('colors', $data)) {
+ $this->colors = $data['colors'];
+ }
+ } else {
+ $this->setData($data);
+ }
+
+ if (isset($colors)) {
+ $this->setColors($colors);
+ } else {
+ $this->setColors($this->colors);
+ }
+ }
+
+ /**
+ * Renders this widget via the given view and returns the
+ * HTML as a string
+ *
+ * @return string
+ */
+ public function render()
+ {
+ $pie = new PieChart();
+ $pie->alignTopLeft();
+ $pie->disableLegend();
+ $pie->drawPie([
+ 'data' => $this->data,
+ 'colors' => $this->colors
+ ]);
+
+ if ($this->view()->layout()->getLayout() === 'pdf') {
+ try {
+ $png = $pie->toPng($this->size, $this->size);
+ return '<img class="inlinepie" src="data:image/png;base64,' . base64_encode($png) . '" />';
+ } catch (IcingaException $_) {
+ return '';
+ }
+ }
+
+ $pie->title = $this->title;
+ $pie->description = $this->title;
+
+ $template = $this->template;
+ $template = str_replace('{class}', $this->class, $template);
+ $template = str_replace('{svg}', $pie->render(), $template);
+
+ return $template;
+ }
+
+ public static function createFromStateSummary(stdClass $states, $title, array $colors)
+ {
+ $handledUnhandledStates = [];
+ foreach ($states as $key => $value) {
+ if (StringHelper::endsWith($key, '_handled') || StringHelper::endsWith($key, '_unhandled')) {
+ $handledUnhandledStates[$key] = $value;
+ }
+ }
+
+ $chart = new self(array_values($handledUnhandledStates), $title, $colors);
+
+ return $chart
+ ->setSize(50)
+ ->setTitle('')
+ ->setSparklineClass('sparkline-multi');
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard.php b/library/Icinga/Web/Widget/Dashboard.php
new file mode 100644
index 0000000..5a8796d
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard.php
@@ -0,0 +1,475 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Config;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Legacy\DashboardConfig;
+use Icinga\User;
+use Icinga\Web\Navigation\DashboardPane;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Dashboard\Dashlet as DashboardDashlet;
+use Icinga\Web\Widget\Dashboard\Pane;
+
+/**
+ * Dashboards display multiple views on a single page
+ *
+ * The terminology is as follows:
+ * - Dashlet: A single view showing a specific url
+ * - Pane: Aggregates one or more dashlets on one page, displays its title as a tab
+ * - Dashboard: Shows all panes
+ *
+ */
+class Dashboard extends AbstractWidget
+{
+ /**
+ * An array containing all panes of this dashboard
+ *
+ * @var array
+ */
+ private $panes = array();
+
+ /**
+ * The @see Icinga\Web\Widget\Tabs object for displaying displayable panes
+ *
+ * @var Tabs
+ */
+ protected $tabs;
+
+ /**
+ * The parameter that will be added to identify panes
+ *
+ * @var string
+ */
+ private $tabParam = 'pane';
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * Set the given tab name as active.
+ *
+ * @param string $name The tab name to activate
+ *
+ */
+ public function activate($name)
+ {
+ $this->getTabs()->activate($name);
+ }
+
+ /**
+ * Load Pane items provided by all enabled modules
+ *
+ * @return $this
+ */
+ public function load()
+ {
+ $navigation = new Navigation();
+ $navigation->load('dashboard-pane');
+
+ $panes = array();
+ foreach ($navigation as $dashboardPane) {
+ /** @var DashboardPane $dashboardPane */
+ $pane = new Pane($dashboardPane->getLabel());
+ foreach ($dashboardPane->getChildren() as $dashlet) {
+ $pane->addDashlet($dashlet->getLabel(), $dashlet->getUrl());
+ }
+
+ $panes[] = $pane;
+ }
+
+ $this->mergePanes($panes);
+ $this->loadUserDashboards($navigation);
+ return $this;
+ }
+
+ /**
+ * Create and return a Config object for this dashboard
+ *
+ * @return Config
+ */
+ public function getConfig()
+ {
+ $output = array();
+ foreach ($this->panes as $pane) {
+ if ($pane->isUserWidget()) {
+ $output[$pane->getName()] = $pane->toArray();
+ }
+ foreach ($pane->getDashlets() as $dashlet) {
+ if ($dashlet->isUserWidget()) {
+ $output[$pane->getName() . '.' . $dashlet->getName()] = $dashlet->toArray();
+ }
+ }
+ }
+
+ return DashboardConfig::fromArray($output)->setConfigFile($this->getConfigFile())->setUser($this->user);
+ }
+
+ /**
+ * Load user dashboards from all config files that match the username
+ */
+ protected function loadUserDashboards(Navigation $navigation)
+ {
+ foreach (DashboardConfig::listConfigFilesForUser($this->user) as $file) {
+ $this->loadUserDashboardsFromFile($file, $navigation);
+ }
+ }
+
+ /**
+ * Load user dashboards from the given config file
+ *
+ * @param string $file
+ *
+ * @return bool
+ */
+ protected function loadUserDashboardsFromFile($file, Navigation $dashboardNavigation)
+ {
+ try {
+ $config = Config::fromIni($file);
+ } catch (NotReadableError $e) {
+ return false;
+ }
+
+ if (! count($config)) {
+ return false;
+ }
+ $panes = array();
+ $dashlets = array();
+ foreach ($config as $key => $part) {
+ if (strpos($key, '.') === false) {
+ $dashboardPane = $dashboardNavigation->getItem($key);
+ if ($dashboardPane !== null) {
+ $key = $dashboardPane->getLabel();
+ }
+ if ($this->hasPane($key)) {
+ $panes[$key] = $this->getPane($key);
+ } else {
+ $panes[$key] = new Pane($key);
+ $panes[$key]->setTitle($part->title);
+ }
+ $panes[$key]->setUserWidget();
+ if ((bool) $part->get('disabled', false) === true) {
+ $panes[$key]->setDisabled();
+ }
+ } else {
+ list($paneName, $dashletName) = explode('.', $key, 2);
+ $dashboardPane = $dashboardNavigation->getItem($paneName);
+ if ($dashboardPane !== null) {
+ $paneName = $dashboardPane->getLabel();
+ $dashletItem = $dashboardPane->getChildren()->getItem($dashletName);
+ if ($dashletItem !== null) {
+ $dashletName = $dashletItem->getLabel();
+ }
+ }
+ $part->pane = $paneName;
+ $part->dashlet = $dashletName;
+ $dashlets[] = $part;
+ }
+ }
+ foreach ($dashlets as $dashletData) {
+ $pane = null;
+
+ if (array_key_exists($dashletData->pane, $panes) === true) {
+ $pane = $panes[$dashletData->pane];
+ } elseif (array_key_exists($dashletData->pane, $this->panes) === true) {
+ $pane = $this->panes[$dashletData->pane];
+ } else {
+ continue;
+ }
+ $dashlet = new DashboardDashlet(
+ $dashletData->title,
+ $dashletData->url,
+ $pane
+ );
+ $dashlet->setName($dashletData->dashlet);
+
+ if ((bool) $dashletData->get('disabled', false) === true) {
+ $dashlet->setDisabled(true);
+ }
+
+ $dashlet->setUserWidget();
+ $pane->addDashlet($dashlet);
+ }
+
+ $this->mergePanes($panes);
+
+ return true;
+ }
+
+ /**
+ * Merge panes with existing panes
+ *
+ * @param array $panes
+ *
+ * @return $this
+ */
+ public function mergePanes(array $panes)
+ {
+ /** @var $pane Pane */
+ foreach ($panes as $pane) {
+ if ($this->hasPane($pane->getName()) === true) {
+ /** @var $current Pane */
+ $current = $this->panes[$pane->getName()];
+ $current->addDashlets($pane->getDashlets());
+ } else {
+ $this->panes[$pane->getName()] = $pane;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the tab object used to navigate through this dashboard
+ *
+ * @return Tabs
+ */
+ public function getTabs()
+ {
+ $url = Url::fromPath('dashboard')->getUrlWithout($this->tabParam);
+ if ($this->tabs === null) {
+ $this->tabs = new Tabs();
+
+ foreach ($this->panes as $key => $pane) {
+ if ($pane->getDisabled()) {
+ continue;
+ }
+ $this->tabs->add(
+ $key,
+ array(
+ 'title' => sprintf(
+ t('Show %s', 'dashboard.pane.tooltip'),
+ $pane->getTitle()
+ ),
+ 'label' => $pane->getTitle(),
+ 'url' => clone($url),
+ 'urlParams' => array($this->tabParam => $key)
+ )
+ );
+ }
+ }
+ return $this->tabs;
+ }
+
+ /**
+ * Return all panes of this dashboard
+ *
+ * @return array
+ */
+ public function getPanes()
+ {
+ return $this->panes;
+ }
+
+
+ /**
+ * Creates a new empty pane with the given title
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function createPane($title)
+ {
+ $pane = new Pane($title);
+ $pane->setTitle($title);
+ $this->addPane($pane);
+
+ return $this;
+ }
+
+ /**
+ * Checks if the current dashboard has any panes
+ *
+ * @return bool
+ */
+ public function hasPanes()
+ {
+ return ! empty($this->panes);
+ }
+
+ /**
+ * Check if a panel exist
+ *
+ * @param string $pane
+ * @return bool
+ */
+ public function hasPane($pane)
+ {
+ return $pane && array_key_exists($pane, $this->panes);
+ }
+
+ /**
+ * Add a pane object to this dashboard
+ *
+ * @param Pane $pane The pane to add
+ *
+ * @return $this
+ */
+ public function addPane(Pane $pane)
+ {
+ $this->panes[$pane->getName()] = $pane;
+ return $this;
+ }
+
+ public function removePane($title)
+ {
+ if ($this->hasPane($title) === true) {
+ $pane = $this->getPane($title);
+ if ($pane->isUserWidget() === true) {
+ unset($this->panes[$pane->getName()]);
+ } else {
+ $pane->setDisabled();
+ $pane->setUserWidget();
+ }
+ } else {
+ throw new ProgrammingError('Pane not found: ' . $title);
+ }
+ }
+
+ /**
+ * Return the pane with the provided name
+ *
+ * @param string $name The name of the pane to return
+ *
+ * @return Pane The pane or null if no pane with the given name exists
+ * @throws ProgrammingError
+ */
+ public function getPane($name)
+ {
+ if (! array_key_exists($name, $this->panes)) {
+ throw new ProgrammingError(
+ 'Trying to retrieve invalid dashboard pane "%s"',
+ $name
+ );
+ }
+ return $this->panes[$name];
+ }
+
+ /**
+ * Return an array with pane name=>title format used for comboboxes
+ *
+ * @return array
+ */
+ public function getPaneKeyTitleArray()
+ {
+ $list = array();
+ foreach ($this->panes as $name => $pane) {
+ $list[$name] = $pane->getTitle();
+ }
+ return $list;
+ }
+
+ /**
+ * @see Icinga\Web\Widget::render
+ */
+ public function render()
+ {
+ if (empty($this->panes)) {
+ return '';
+ }
+
+ return $this->determineActivePane()->render();
+ }
+
+ /**
+ * Activates the default pane of this dashboard and returns its name
+ *
+ * @return mixed
+ */
+ private function setDefaultPane()
+ {
+ $active = null;
+
+ foreach ($this->panes as $key => $pane) {
+ if ($pane->getDisabled() === false) {
+ $active = $key;
+ break;
+ }
+ }
+
+ if ($active !== null) {
+ $this->activate($active);
+ }
+ return $active;
+ }
+
+ /**
+ * @see determineActivePane()
+ */
+ public function getActivePane()
+ {
+ return $this->determineActivePane();
+ }
+
+ /**
+ * Determine the active pane either by the selected tab or the current request
+ *
+ * @throws \Icinga\Exception\ConfigurationError
+ * @throws \Icinga\Exception\ProgrammingError
+ *
+ * @return Pane The currently active pane
+ */
+ public function determineActivePane()
+ {
+ $active = $this->getTabs()->getActiveName();
+ if (! $active) {
+ if ($active = Url::fromRequest()->getParam($this->tabParam)) {
+ if ($this->hasPane($active)) {
+ $this->activate($active);
+ } else {
+ throw new ProgrammingError(
+ 'Try to get an inexistent pane.'
+ );
+ }
+ } else {
+ $active = $this->setDefaultPane();
+ }
+ }
+
+ if (isset($this->panes[$active])) {
+ return $this->panes[$active];
+ }
+
+ throw new ConfigurationError('Could not determine active pane');
+ }
+
+ /**
+ * Setter for user object
+ *
+ * @param User $user
+ */
+ public function setUser(User $user)
+ {
+ $this->user = $user;
+ }
+
+ /**
+ * Getter for user object
+ *
+ * @return User
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Get config file
+ *
+ * @return string
+ */
+ public function getConfigFile()
+ {
+ if ($this->user === null) {
+ throw new ProgrammingError('Can\'t load dashboards. User is not set');
+ }
+ return Config::resolvePath('dashboards/' . strtolower($this->user->getUsername()) . '/dashboard.ini');
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard/Dashlet.php b/library/Icinga/Web/Widget/Dashboard/Dashlet.php
new file mode 100644
index 0000000..2ba26df
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard/Dashlet.php
@@ -0,0 +1,315 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Dashboard;
+
+use Icinga\Web\Url;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\IcingaException;
+
+/**
+ * A dashboard pane dashlet
+ *
+ * This is the element displaying a specific view in icinga2web
+ *
+ */
+class Dashlet extends UserWidget
+{
+ /**
+ * The url of this Dashlet
+ *
+ * @var Url|null
+ */
+ private $url;
+
+ private $name;
+
+ /**
+ * The title being displayed on top of the dashlet
+ * @var
+ */
+ private $title;
+
+ /**
+ * The pane containing this dashlet, needed for the 'remove button'
+ * @var Pane
+ */
+ private $pane;
+
+ /**
+ * The disabled option is used to "delete" default dashlets provided by modules
+ *
+ * @var bool
+ */
+ private $disabled = false;
+
+ /**
+ * The progress label being used
+ *
+ * @var string
+ */
+ private $progressLabel;
+
+ /**
+ * The template string used for rendering this widget
+ *
+ * @var string
+ */
+ private $template =<<<'EOD'
+
+ <div class="container" data-icinga-url="{URL}">
+ <h1><a href="{FULL_URL}" aria-label="{TOOLTIP}" title="{TOOLTIP}" data-base-target="col1">{TITLE}</a></h1>
+ <p class="progress-label">{PROGRESS_LABEL}<span>.</span><span>.</span><span>.</span></p>
+ <noscript>
+ <div class="iframe-container">
+ <iframe
+ src="{IFRAME_URL}"
+ frameborder="no"
+ title="{TITLE_PREFIX}{TITLE}">
+ </iframe>
+ </div>
+ </noscript>
+ </div>
+EOD;
+
+ /**
+ * The template string used for rendering this widget in case of an error
+ *
+ * @var string
+ */
+ private $errorTemplate = <<<'EOD'
+
+ <div class="container">
+ <h1 title="{TOOLTIP}">{TITLE}</h1>
+ <p class="error-message">{ERROR_MESSAGE}</p>
+ </div>
+EOD;
+
+ /**
+ * Create a new dashlet displaying the given url in the provided pane
+ *
+ * @param string $title The title to use for this dashlet
+ * @param Url|string $url The url this dashlet uses for displaying information
+ * @param Pane $pane The pane this Dashlet will be added to
+ */
+ public function __construct($title, $url, Pane $pane)
+ {
+ $this->name = $title;
+ $this->title = $title;
+ $this->pane = $pane;
+ $this->url = $url;
+ }
+
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Retrieve the dashlets title
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ /**
+ * @param string $title
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ }
+
+ /**
+ * Retrieve the dashlets url
+ *
+ * @return Url|null
+ */
+ public function getUrl()
+ {
+ if ($this->url !== null && ! $this->url instanceof Url) {
+ $this->url = Url::fromPath($this->url);
+ }
+ return $this->url;
+ }
+
+ /**
+ * Set the dashlets URL
+ *
+ * @param string|Url $url The url to use, either as an Url object or as a path
+ *
+ * @return $this
+ */
+ public function setUrl($url)
+ {
+ $this->url = $url;
+ return $this;
+ }
+
+ /**
+ * Set the disabled property
+ *
+ * @param boolean $disabled
+ */
+ public function setDisabled($disabled)
+ {
+ $this->disabled = $disabled;
+ }
+
+ /**
+ * Get the disabled property
+ *
+ * @return boolean
+ */
+ public function getDisabled()
+ {
+ return $this->disabled;
+ }
+
+ /**
+ * Set the progress label to use
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setProgressLabel($label)
+ {
+ $this->progressLabel = $label;
+ return $this;
+ }
+
+ /**
+ * Return the progress label to use
+ *
+ * @return string
+ */
+ public function getProgressLabe()
+ {
+ if ($this->progressLabel === null) {
+ return $this->view()->translate('Loading');
+ }
+
+ return $this->progressLabel;
+ }
+
+ /**
+ * Return this dashlet's structure as array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $array = array(
+ 'url' => $this->getUrl()->getRelativeUrl(),
+ 'title' => $this->getTitle()
+ );
+ if ($this->getDisabled() === true) {
+ $array['disabled'] = 1;
+ }
+ return $array;
+ }
+
+ /**
+ * @see Widget::render()
+ */
+ public function render()
+ {
+ if ($this->disabled === true) {
+ return '';
+ }
+
+ $view = $this->view();
+
+ if (! $this->url) {
+ $searchTokens = array(
+ '{TOOLTIP}',
+ '{TITLE}',
+ '{ERROR_MESSAGE}'
+ );
+
+ $replaceTokens = array(
+ sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())),
+ $view->escape($this->getTitle()),
+ $view->escape(
+ sprintf($view->translate('Cannot create dashboard dashlet "%s" without valid URL'), $this->title)
+ )
+ );
+
+ return str_replace($searchTokens, $replaceTokens, $this->errorTemplate);
+ }
+
+ $url = $this->getUrl();
+ $url->setParam('showCompact', true);
+ $iframeUrl = clone $url;
+ $iframeUrl->setParam('isIframe');
+
+ $searchTokens = array(
+ '{URL}',
+ '{IFRAME_URL}',
+ '{FULL_URL}',
+ '{TOOLTIP}',
+ '{TITLE}',
+ '{TITLE_PREFIX}',
+ '{PROGRESS_LABEL}'
+ );
+
+ $replaceTokens = array(
+ $url,
+ $iframeUrl,
+ $url->getUrlWithout(['showCompact', 'limit', 'view']),
+ sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())),
+ $view->escape($this->getTitle()),
+ $view->translate('Dashlet') . ': ',
+ $this->getProgressLabe()
+ );
+
+ return str_replace($searchTokens, $replaceTokens, $this->template);
+ }
+
+ /**
+ * Create a @see Dashlet instance from the given Zend config, using the provided title
+ *
+ * @param $title The title for this dashlet
+ * @param ConfigObject $config The configuration defining url, parameters, height, width, etc.
+ * @param Pane $pane The pane this dashlet belongs to
+ *
+ * @return Dashlet A newly created Dashlet for use in the Dashboard
+ */
+ public static function fromIni($title, ConfigObject $config, Pane $pane)
+ {
+ $height = null;
+ $width = null;
+ $url = $config->get('url');
+ $parameters = $config->toArray();
+ unset($parameters['url']); // otherwise there's an url = parameter in the Url
+
+ $cmp = new Dashlet($title, Url::fromPath($url, $parameters), $pane);
+ return $cmp;
+ }
+
+ /**
+ * @param \Icinga\Web\Widget\Dashboard\Pane $pane
+ */
+ public function setPane(Pane $pane)
+ {
+ $this->pane = $pane;
+ }
+
+ /**
+ * @return \Icinga\Web\Widget\Dashboard\Pane
+ */
+ public function getPane()
+ {
+ return $this->pane;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard/Pane.php b/library/Icinga/Web/Widget/Dashboard/Pane.php
new file mode 100644
index 0000000..c8b14c5
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard/Pane.php
@@ -0,0 +1,335 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Dashboard;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Web\Widget\AbstractWidget;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * A pane, displaying different Dashboard dashlets
+ */
+class Pane extends UserWidget
+{
+ /**
+ * The name of this pane, as defined in the ini file
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * The title of this pane, as displayed in the dashboard tabs
+ *
+ * @var string
+ */
+ private $title;
+
+ /**
+ * An array of @see Dashlets that are displayed in this pane
+ *
+ * @var array
+ */
+ private $dashlets = array();
+
+ /**
+ * Disabled flag of a pane
+ *
+ * @var bool
+ */
+ private $disabled = false;
+
+ /**
+ * Create a new pane
+ *
+ * @param string $name The pane to create
+ */
+ public function __construct($name)
+ {
+ $this->name = $name;
+ $this->title = $name;
+ }
+
+ /**
+ * Set the name of this pane
+ *
+ * @param string $name
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ }
+
+ /**
+ * Returns the name of this pane
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Returns the title of this pane
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ /**
+ * Overwrite the title of this pane
+ *
+ * @param string $title The new title to use for this pane
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * Return true if a dashlet with the given title exists in this pane
+ *
+ * @param string $title The title of the dashlet to check for existence
+ *
+ * @return bool
+ */
+ public function hasDashlet($title)
+ {
+ return array_key_exists($title, $this->dashlets);
+ }
+
+ /**
+ * Checks if the current pane has any dashlets
+ *
+ * @return bool
+ */
+ public function hasDashlets()
+ {
+ return ! empty($this->dashlets);
+ }
+
+ /**
+ * Return a dashlet with the given name if existing
+ *
+ * @param string $title The title of the dashlet to return
+ *
+ * @return Dashlet The dashlet with the given title
+ * @throws ProgrammingError If the dashlet doesn't exist
+ */
+ public function getDashlet($title)
+ {
+ if ($this->hasDashlet($title)) {
+ return $this->dashlets[$title];
+ }
+ throw new ProgrammingError(
+ 'Trying to access invalid dashlet: %s',
+ $title
+ );
+ }
+
+ /**
+ * Removes the dashlet with the given title if it exists in this pane
+ *
+ * @param string $title The pane
+ * @return Pane $this
+ */
+ public function removeDashlet($title)
+ {
+ if ($this->hasDashlet($title)) {
+ $dashlet = $this->getDashlet($title);
+ if ($dashlet->isUserWidget() === true) {
+ unset($this->dashlets[$title]);
+ } else {
+ $dashlet->setDisabled(true);
+ $dashlet->setUserWidget();
+ }
+ } else {
+ throw new ProgrammingError('Dashlet does not exist: ' . $title);
+ }
+ return $this;
+ }
+
+ /**
+ * Removes all or a given list of dashlets from this pane
+ *
+ * @param array $dashlets Optional list of dashlet titles
+ * @return Pane $this
+ */
+ public function removeDashlets(array $dashlets = null)
+ {
+ if ($dashlets === null) {
+ $this->dashlets = array();
+ } else {
+ foreach ($dashlets as $dashlet) {
+ $this->removeDashlet($dashlet);
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Return all dashlets added at this pane
+ *
+ * @return array
+ */
+ public function getDashlets()
+ {
+ return $this->dashlets;
+ }
+
+ /**
+ * @see Widget::render
+ */
+ public function render()
+ {
+ $dashlets = array_filter(
+ $this->dashlets,
+ function ($e) {
+ return ! $e->getDisabled();
+ }
+ );
+ return implode("\n", $dashlets) . "\n";
+ }
+
+ /**
+ * Create, add and return a new dashlet
+ *
+ * @param string $title
+ * @param string $url
+ *
+ * @return Dashlet
+ */
+ public function createDashlet($title, $url = null)
+ {
+ $dashlet = new Dashlet($title, $url, $this);
+ $this->addDashlet($dashlet);
+ return $dashlet;
+ }
+
+ /**
+ * Add a dashlet to this pane, optionally creating it if $dashlet is a string
+ *
+ * @param string|Dashlet $dashlet The dashlet object or title
+ * (if a new dashlet will be created)
+ * @param string|null $url An Url to be used when dashlet is a string
+ *
+ * @return $this
+ * @throws \Icinga\Exception\ConfigurationError
+ */
+ public function addDashlet($dashlet, $url = null)
+ {
+ if ($dashlet instanceof Dashlet) {
+ $this->dashlets[$dashlet->getName()] = $dashlet;
+ } elseif (is_string($dashlet) && $url !== null) {
+ $this->createDashlet($dashlet, $url);
+ } else {
+ throw new ConfigurationError('Invalid dashlet added: %s', $dashlet);
+ }
+ return $this;
+ }
+
+ /**
+ * Add new dashlets to existing dashlets
+ *
+ * @param array $dashlets
+ * @return $this
+ */
+ public function addDashlets(array $dashlets)
+ {
+ /* @var $dashlet Dashlet */
+ foreach ($dashlets as $dashlet) {
+ if (array_key_exists($dashlet->getName(), $this->dashlets)) {
+ if (preg_match('/_(\d+)$/', $dashlet->getName(), $m)) {
+ $name = preg_replace('/_\d+$/', $m[1]++, $dashlet->getName());
+ } else {
+ $name = $dashlet->getName() . '_2';
+ }
+ $this->dashlets[$name] = $dashlet;
+ } else {
+ $this->dashlets[$dashlet->getName()] = $dashlet;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a dashlet to the current pane
+ *
+ * @param $title
+ * @param $url
+ * @return Dashlet
+ *
+ * @see addDashlet()
+ */
+ public function add($title, $url = null)
+ {
+ $this->addDashlet($title, $url);
+
+ return $this->dashlets[$title];
+ }
+
+ /**
+ * Return the this pane's structure as array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $pane = array(
+ 'title' => $this->getTitle(),
+ );
+
+ if ($this->getDisabled() === true) {
+ $pane['disabled'] = 1;
+ }
+
+ return $pane;
+ }
+
+ /**
+ * Create a new pane with the title $title from the given configuration
+ *
+ * @param $title The title for this pane
+ * @param ConfigObject $config The configuration to use for setup
+ *
+ * @return Pane
+ */
+ public static function fromIni($title, ConfigObject $config)
+ {
+ $pane = new Pane($title);
+ if ($config->get('title', false)) {
+ $pane->setTitle($config->get('title'));
+ }
+ return $pane;
+ }
+
+ /**
+ * Setter for disabled
+ *
+ * @param boolean $disabled
+ */
+ public function setDisabled($disabled = true)
+ {
+ $this->disabled = (bool) $disabled;
+ }
+
+ /**
+ * Getter for disabled
+ *
+ * @return boolean
+ */
+ public function getDisabled()
+ {
+ return $this->disabled;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard/UserWidget.php b/library/Icinga/Web/Widget/Dashboard/UserWidget.php
new file mode 100644
index 0000000..164d58b
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard/UserWidget.php
@@ -0,0 +1,36 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Dashboard;
+
+use Icinga\Web\Widget\AbstractWidget;
+
+abstract class UserWidget extends AbstractWidget
+{
+ /**
+ * Flag if widget is created by an user
+ *
+ * @var bool
+ */
+ protected $userWidget = false;
+
+ /**
+ * Set the user widget flag
+ *
+ * @param boolean $userWidget
+ */
+ public function setUserWidget($userWidget = true)
+ {
+ $this->userWidget = (bool) $userWidget;
+ }
+
+ /**
+ * Getter for user widget flag
+ *
+ * @return boolean
+ */
+ public function isUserWidget()
+ {
+ return $this->userWidget;
+ }
+}
diff --git a/library/Icinga/Web/Widget/FilterEditor.php b/library/Icinga/Web/Widget/FilterEditor.php
new file mode 100644
index 0000000..24f4b15
--- /dev/null
+++ b/library/Icinga/Web/Widget/FilterEditor.php
@@ -0,0 +1,811 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Data\Filterable;
+use Icinga\Data\FilterColumns;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterOr;
+use Icinga\Web\Url;
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Notification;
+use Exception;
+
+/**
+ * Filter
+ */
+class FilterEditor extends AbstractWidget
+{
+ /**
+ * The filter
+ *
+ * @var Filter
+ */
+ private $filter;
+
+ /**
+ * The query to filter
+ *
+ * @var Filterable
+ */
+ protected $query;
+
+ protected $url;
+
+ protected $addTo;
+
+ protected $cachedColumnSelect;
+
+ protected $preserveParams = array();
+
+ protected $preservedParams = array();
+
+ protected $preservedUrl;
+
+ protected $ignoreParams = array();
+
+ protected $searchColumns;
+
+ /**
+ * @var string
+ */
+ private $selectedIdx;
+
+ /**
+ * Whether the filter control is visible
+ *
+ * @var bool
+ */
+ protected $visible = true;
+
+ /**
+ * Create a new FilterEditor
+ *
+ * @param Filter $filter Your filter
+ */
+ public function __construct($props)
+ {
+ if (array_key_exists('filter', $props)) {
+ $this->setFilter($props['filter']);
+ }
+ if (array_key_exists('query', $props)) {
+ $this->setQuery($props['query']);
+ }
+ }
+
+ public function setFilter(Filter $filter)
+ {
+ $this->filter = $filter;
+ return $this;
+ }
+
+ public function getFilter()
+ {
+ if ($this->filter === null) {
+ $this->filter = Filter::fromQueryString((string) $this->url()->getParams());
+ }
+ return $this->filter;
+ }
+
+ /**
+ * Set columns to search in
+ *
+ * @param array $searchColumns
+ *
+ * @return $this
+ */
+ public function setSearchColumns(array $searchColumns = null)
+ {
+ $this->searchColumns = $searchColumns;
+ return $this;
+ }
+
+ public function setUrl($url)
+ {
+ $this->url = $url;
+ return $this;
+ }
+
+ protected function url()
+ {
+ if ($this->url === null) {
+ $this->url = Url::fromRequest();
+ }
+ return $this->url;
+ }
+
+ protected function preservedUrl()
+ {
+ if ($this->preservedUrl === null) {
+ $this->preservedUrl = $this->url()->with($this->preservedParams);
+ }
+ return $this->preservedUrl;
+ }
+
+ /**
+ * Set the query to filter
+ *
+ * @param Filterable $query
+ *
+ * @return $this
+ */
+ public function setQuery(Filterable $query)
+ {
+ $this->query = $query;
+ return $this;
+ }
+
+ public function ignoreParams()
+ {
+ $this->ignoreParams = func_get_args();
+ return $this;
+ }
+
+ public function preserveParams()
+ {
+ $this->preserveParams = func_get_args();
+ return $this;
+ }
+
+ /**
+ * Get whether the filter control is visible
+ *
+ * @return bool
+ */
+ public function isVisible()
+ {
+ return $this->visible;
+ }
+
+ /**
+ * Set whether the filter control is visible
+ *
+ * @param bool $visible
+ *
+ * @return $this
+ */
+ public function setVisible($visible)
+ {
+ $this->visible = (bool) $visible;
+
+ return $this;
+ }
+
+ protected function redirectNow($url)
+ {
+ $response = Icinga::app()->getFrontController()->getResponse();
+ $response->redirectAndExit($url);
+ }
+
+ protected function mergeRootExpression($filter, $column, $sign, $expression)
+ {
+ $found = false;
+ if ($filter->isChain() && $filter->getOperatorName() === 'AND') {
+ foreach ($filter->filters() as $f) {
+ if ($f->isExpression()
+ && $f->getColumn() === $column
+ && $f->getSign() === $sign
+ ) {
+ $f->setExpression($expression);
+ $found = true;
+ break;
+ }
+ }
+ } elseif ($filter->isExpression()) {
+ if ($filter->getColumn() === $column && $filter->getSign() === $sign) {
+ $filter->setExpression($expression);
+ $found = true;
+ }
+ }
+ if (! $found) {
+ $filter = $filter->andFilter(
+ Filter::expression($column, $sign, $expression)
+ );
+ }
+ return $filter;
+ }
+
+ protected function resetSearchColumns(Filter &$filter)
+ {
+ if ($filter->isChain()) {
+ $filters = &$filter->filters();
+ if (!($empty = empty($filters))) {
+ foreach ($filters as $k => &$f) {
+ if (false === $this->resetSearchColumns($f)) {
+ unset($filters[$k]);
+ }
+ }
+ }
+ return $empty || !empty($filters);
+ }
+ return $filter->isExpression() ? !(
+ in_array($filter->getColumn(), $this->searchColumns)
+ &&
+ $filter->getSign() === '='
+ ) : true;
+ }
+
+ public function handleRequest($request)
+ {
+ $this->setUrl($request->getUrl()->without($this->ignoreParams));
+ $params = $this->url()->getParams();
+
+ $preserve = array();
+ foreach ($this->preserveParams as $key) {
+ if (null !== ($value = $params->shift($key))) {
+ $preserve[$key] = $value;
+ }
+ }
+ $this->preservedParams = $preserve;
+
+ $add = $params->shift('addFilter');
+ $remove = $params->shift('removeFilter');
+ $strip = $params->shift('stripFilter');
+ $modify = $params->shift('modifyFilter');
+
+
+
+ $search = null;
+ if ($request->isPost()) {
+ $search = $request->getPost('q');
+ }
+
+ if ($search === null) {
+ $search = $params->shift('q');
+ }
+
+ $filter = $this->getFilter();
+
+ if ($search !== null) {
+ if (strpos($search, '=') !== false) {
+ list($k, $v) = preg_split('/=/', $search);
+ $filter = $this->mergeRootExpression($filter, trim($k), '=', ltrim($v));
+ } else {
+ if ($this->searchColumns === null && $this->query instanceof FilterColumns) {
+ $this->searchColumns = $this->query->getSearchColumns($search);
+ }
+
+ if (! empty($this->searchColumns)) {
+ if (! $this->resetSearchColumns($filter)) {
+ $filter = Filter::matchAll();
+ }
+ $filters = array();
+ $search = trim($search);
+ foreach ($this->searchColumns as $searchColumn) {
+ $filters[] = Filter::expression($searchColumn, '=', "*$search*");
+ }
+ $filter = $filter->andFilter(new FilterOr($filters));
+ } else {
+ Notification::error(mt('monitoring', 'Cannot search here'));
+ return $this;
+ }
+ }
+
+ $url = Url::fromRequest()->onlyWith($this->preserveParams);
+ $urlParams = $url->getParams();
+ $url->setQueryString($filter->toQueryString());
+ foreach ($urlParams->toArray(false) as $key => $value) {
+ $url->getParams()->addEncoded($key, $value);
+ }
+
+ $this->redirectNow($url);
+ }
+
+ if ($remove) {
+ $redirect = $this->url();
+ if ($filter->getById($remove)->isRootNode()) {
+ $redirect->setQueryString('');
+ } else {
+ $filter->removeId($remove);
+ $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter');
+ }
+ $this->redirectNow($redirect->addParams($preserve));
+ }
+
+ if ($strip) {
+ $redirect = $this->url();
+ $subId = $strip . '-1';
+ if ($filter->getId() === $strip) {
+ $filter = $filter->getById($strip . '-1');
+ } else {
+ $filter->replaceById($strip, $filter->getById($strip . '-1'));
+ }
+ $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter');
+ $this->redirectNow($redirect->addParams($preserve));
+ }
+
+
+ if ($modify) {
+ if ($request->isPost()) {
+ if ($request->get('cancel') === 'Cancel') {
+ $this->redirectNow($this->preservedUrl()->without('modifyFilter'));
+ }
+ if ($request->get('formUID') === 'FilterEditor') {
+ $filter = $this->applyChanges($request->getPost());
+ $url = $this->url()->setQueryString($filter->toQueryString())->addParams($preserve);
+ $url->getParams()->add('modifyFilter');
+
+ $addFilter = $request->get('add_filter');
+ if ($addFilter !== null) {
+ $url->setParam('addFilter', $addFilter);
+ }
+
+ $removeFilter = $request->get('remove_filter');
+ if ($removeFilter !== null) {
+ $url->setParam('removeFilter', $removeFilter);
+ }
+
+ $this->redirectNow($url);
+ }
+ }
+ $this->url()->getParams()->add('modifyFilter');
+ }
+
+ if ($add) {
+ $this->addFilterToId($add);
+ }
+
+ if ($this->query !== null && $request->isGet()) {
+ $this->query->applyFilter($this->getFilter());
+ }
+
+ return $this;
+ }
+
+ protected function select($name, $list, $selected, $attributes = null)
+ {
+ $view = $this->view();
+ if ($attributes === null) {
+ $attributes = '';
+ } else {
+ $attributes = $view->propertiesToString($attributes);
+ }
+ $html = sprintf(
+ '<select name="%s"%s class="autosubmit">' . "\n",
+ $view->escape($name),
+ $attributes
+ );
+
+ foreach ($list as $k => $v) {
+ $active = '';
+ if ($k === $selected) {
+ $active = ' selected="selected"';
+ }
+ $html .= sprintf(
+ ' <option value="%s"%s>%s</option>' . "\n",
+ $view->escape($k),
+ $active,
+ $view->escape($v)
+ );
+ }
+ $html .= '</select>' . "\n\n";
+ return $html;
+ }
+
+ protected function addFilterToId($id)
+ {
+ $this->addTo = $id;
+ return $this;
+ }
+
+ protected function removeIndex($idx)
+ {
+ $this->selectedIdx = $idx;
+ return $this;
+ }
+
+ protected function removeLink(Filter $filter)
+ {
+ return "<button type='submit' name='remove_filter' value='{$filter->getId()}'>"
+ . $this->view()->icon('trash', t('Remove this part of your filter'))
+ . '</button>';
+ }
+
+ protected function addLink(Filter $filter)
+ {
+ return "<button type='submit' name='add_filter' value='{$filter->getId()}'>"
+ . $this->view()->icon('plus', t('Add another filter'))
+ . '</button>';
+ }
+
+ protected function stripLink(Filter $filter)
+ {
+ return $this->view()->qlink(
+ '',
+ $this->preservedUrl()->with('stripFilter', $filter->getId()),
+ null,
+ array(
+ 'icon' => 'minus',
+ 'title' => t('Strip this filter')
+ )
+ );
+ }
+
+ protected function cancelLink()
+ {
+ return $this->view()->qlink(
+ '',
+ $this->preservedUrl()->without('addFilter'),
+ null,
+ array(
+ 'icon' => 'cancel',
+ 'title' => t('Cancel this operation')
+ )
+ );
+ }
+
+ protected function renderFilter($filter, $level = 0)
+ {
+ if ($level === 0 && $filter->isChain() && $filter->isEmpty()) {
+ return '<ul class="datafilter"><li class="active">' . $this->renderNewFilter() . '</li></ul>';
+ }
+
+ if ($filter instanceof FilterChain) {
+ return $this->renderFilterChain($filter, $level);
+ } elseif ($filter instanceof FilterExpression) {
+ return $this->renderFilterExpression($filter);
+ } else {
+ throw new ProgrammingError('Got a Filter being neither expression nor chain');
+ }
+ }
+
+ protected function renderFilterChain(FilterChain $filter, $level)
+ {
+ $html = '<span class="handle"> </span>'
+ . $this->selectOperator($filter)
+ . $this->removeLink($filter)
+ . ($filter->count() === 1 ? $this->stripLink($filter) : '')
+ . $this->addLink($filter);
+
+ if ($filter->isEmpty() && ! $this->addTo) {
+ return $html;
+ }
+
+ $parts = array();
+ foreach ($filter->filters() as $f) {
+ $parts[] = '<li>' . $this->renderFilter($f, $level + 1) . '</li>';
+ }
+
+ if ($this->addTo && $this->addTo == $filter->getId()) {
+ $parts[] = '<li class="new-filter">' . $this->renderNewFilter() .$this->cancelLink(). '</li>';
+ }
+
+ $class = $level === 0 ? ' class="datafilter"' : '';
+ $html .= sprintf(
+ "<ul%s>\n%s</ul>\n",
+ $class,
+ implode("", $parts)
+ );
+ return $html;
+ }
+
+ protected function renderFilterExpression(FilterExpression $filter)
+ {
+ if ($this->addTo && $this->addTo === $filter->getId()) {
+ return
+ preg_replace(
+ '/ class="autosubmit"/',
+ ' class="autofocus"',
+ $this->selectOperator()
+ )
+ . '<ul><li>'
+ . $this->selectColumn($filter)
+ . $this->selectSign($filter)
+ . $this->text($filter)
+ . $this->removeLink($filter)
+ . $this->addLink($filter)
+ . '</li><li class="active">'
+ . $this->renderNewFilter() .$this->cancelLink()
+ . '</li></ul>'
+ ;
+ } else {
+ return $this->selectColumn($filter)
+ . $this->selectSign($filter)
+ . $this->text($filter)
+ . $this->removeLink($filter)
+ . $this->addLink($filter)
+ ;
+ }
+ }
+
+ protected function text(Filter $filter = null)
+ {
+ $value = $filter === null ? '' : $filter->getExpression();
+ if (is_array($value)) {
+ $value = '(' . implode('|', $value) . ')';
+ }
+ return sprintf(
+ '<input type="text" name="%s" value="%s" />',
+ $this->elementId('value', $filter),
+ $this->view()->escape($value)
+ );
+ }
+
+ protected function renderNewFilter()
+ {
+ $html = $this->selectColumn()
+ . $this->selectSign()
+ . $this->text();
+
+ return preg_replace(
+ '/ class="autosubmit"/',
+ '',
+ $html
+ );
+ }
+
+ protected function arrayForSelect($array, $flip = false)
+ {
+ $res = array();
+ foreach ($array as $k => $v) {
+ if (is_int($k)) {
+ $res[$v] = ucwords(str_replace('_', ' ', $v));
+ } elseif ($flip) {
+ $res[$v] = $k;
+ } else {
+ $res[$k] = $v;
+ }
+ }
+ // sort($res);
+ return $res;
+ }
+
+ protected function elementId($prefix, Filter $filter = null)
+ {
+ if ($filter === null) {
+ return $prefix . '_new_' . ($this->addTo ?: '0');
+ } else {
+ return $prefix . '_' . $filter->getId();
+ }
+ }
+
+ protected function selectOperator(Filter $filter = null)
+ {
+ $ops = array(
+ 'AND' => 'AND',
+ 'OR' => 'OR',
+ 'NOT' => 'NOT'
+ );
+
+ return $this->select(
+ $this->elementId('operator', $filter),
+ $ops,
+ $filter === null ? null : $filter->getOperatorName(),
+ ['class' => 'filter-operator']
+ );
+ }
+
+ protected function selectSign(Filter $filter = null)
+ {
+ $signs = array(
+ '=' => '=',
+ '!=' => '!=',
+ '>' => '>',
+ '<' => '<',
+ '>=' => '>=',
+ '<=' => '<=',
+ );
+
+ return $this->select(
+ $this->elementId('sign', $filter),
+ $signs,
+ $filter === null ? null : $filter->getSign(),
+ ['class' => 'filter-rule']
+ );
+ }
+
+ public function setColumns(array $columns = null)
+ {
+ $this->cachedColumnSelect = $columns ? $this->arrayForSelect($columns) : null;
+ return $this;
+ }
+
+ protected function selectColumn(Filter $filter = null)
+ {
+ $active = $filter === null ? null : $filter->getColumn();
+
+ if ($this->cachedColumnSelect === null && $this->query === null) {
+ return sprintf(
+ '<input type="text" name="%s" value="%s" />',
+ $this->elementId('column', $filter),
+ $this->view()->escape($active) // Escape attribute?
+ );
+ }
+
+ if ($this->cachedColumnSelect === null && $this->query instanceof FilterColumns) {
+ $this->cachedColumnSelect = $this->arrayForSelect($this->query->getFilterColumns(), true);
+ asort($this->cachedColumnSelect);
+ } elseif ($this->cachedColumnSelect === null) {
+ throw new ProgrammingError('No columns set nor does the query provide any');
+ }
+
+ $cols = $this->cachedColumnSelect;
+ if ($active && !isset($cols[$active])) {
+ $cols[$active] = str_replace('_', ' ', ucfirst(ltrim($active, '_')));
+ }
+
+ return $this->select($this->elementId('column', $filter), $cols, $active);
+ }
+
+ protected function applyChanges($changes)
+ {
+ $filter = $this->filter;
+ $pairs = array();
+ $addTo = null;
+ $add = array();
+ foreach ($changes as $k => $v) {
+ if (preg_match('/^(column|value|sign|operator)((?:_new)?)_([\d-]+)$/', $k, $m)) {
+ if ($m[2] === '_new') {
+ if ($addTo !== null && $addTo !== $m[3]) {
+ throw new \Exception('F...U');
+ }
+ $addTo = $m[3];
+ $add[$m[1]] = $v;
+ } else {
+ $pairs[$m[3]][$m[1]] = $v;
+ }
+ }
+ }
+
+ $operators = array();
+ foreach ($pairs as $id => $fs) {
+ if (array_key_exists('operator', $fs)) {
+ $operators[$id] = $fs['operator'];
+ } else {
+ $f = $filter->getById($id);
+ $f->setColumn($fs['column']);
+ if ($f->getSign() !== $fs['sign']) {
+ if ($f->isRootNode()) {
+ $filter = $f->setSign($fs['sign']);
+ } else {
+ $filter->replaceById($id, $f->setSign($fs['sign']));
+ }
+ }
+ $f->setExpression($fs['value']);
+ }
+ }
+
+ krsort($operators, SORT_NATURAL);
+ foreach ($operators as $id => $operator) {
+ $f = $filter->getById($id);
+ if ($f->getOperatorName() !== $operator) {
+ if ($f->isRootNode()) {
+ $filter = $f->setOperatorName($operator);
+ } else {
+ $filter->replaceById($id, $f->setOperatorName($operator));
+ }
+ }
+ }
+
+ if ($addTo !== null) {
+ if ($addTo === '0') {
+ $filter = Filter::expression($add['column'], $add['sign'], $add['value']);
+ } else {
+ $parent = $filter->getById($addTo);
+ $f = Filter::expression($add['column'], $add['sign'], $add['value']);
+ if (isset($add['operator'])) {
+ switch ($add['operator']) {
+ case 'AND':
+ if ($parent->isExpression()) {
+ if ($parent->isRootNode()) {
+ $filter = Filter::matchAll(clone $parent, $f);
+ } else {
+ $filter = $filter->replaceById($addTo, Filter::matchAll(clone $parent, $f));
+ }
+ } else {
+ $parent->addFilter(Filter::matchAll($f));
+ }
+ break;
+ case 'OR':
+ if ($parent->isExpression()) {
+ if ($parent->isRootNode()) {
+ $filter = Filter::matchAny(clone $parent, $f);
+ } else {
+ $filter = $filter->replaceById($addTo, Filter::matchAny(clone $parent, $f));
+ }
+ } else {
+ $parent->addFilter(Filter::matchAny($f));
+ }
+ break;
+ case 'NOT':
+ if ($parent->isExpression()) {
+ if ($parent->isRootNode()) {
+ $filter = Filter::not(Filter::matchAll($parent, $f));
+ } else {
+ $filter = $filter->replaceById($addTo, Filter::not(Filter::matchAll($parent, $f)));
+ }
+ } else {
+ $parent->addFilter(Filter::not($f));
+ }
+ break;
+ }
+ } else {
+ $parent->addFilter($f);
+ }
+ }
+ }
+
+ return $filter;
+ }
+
+ public function renderSearch()
+ {
+ $preservedUrl = $this->preservedUrl();
+
+ $html = ' <form method="post" class="search inline" action="'
+ . $preservedUrl
+ . '"><input type="text" name="q" class="search search-input" value="" placeholder="'
+ . t('Search...')
+ . '" /></form>';
+
+ if ($this->filter->isEmpty()) {
+ $title = t('Filter this list');
+ } else {
+ $title = t('Modify this filter');
+ if (! $this->filter->isEmpty()) {
+ $title .= ': ' . $this->view()->escape($this->filter);
+ }
+ }
+
+ return $html
+ . '<a href="'
+ . $preservedUrl->with('modifyFilter', ! $preservedUrl->getParam('modifyFilter'))
+ . '" aria-label="'
+ . $title
+ . '" title="'
+ . $title
+ . '">'
+ . '<i aria-hidden="true" class="icon-filter"></i>'
+ . '</a>';
+ }
+
+ public function render()
+ {
+ if (! $this->visible) {
+ return '';
+ }
+ if (! $this->preservedUrl()->getParam('modifyFilter')) {
+ return '<div class="filter icinga-controls">'
+ . $this->renderSearch()
+ . $this->view()->escape($this->shorten($this->filter, 50))
+ . '</div>';
+ }
+ return '<div class="filter icinga-controls">'
+ . $this->renderSearch()
+ . '<form action="'
+ . Url::fromRequest()
+ . '" class="editor" method="POST">'
+ . '<input type="submit" name="submit" value="Apply" hidden/>'
+ . '<ul class="tree"><li>'
+ . $this->renderFilter($this->filter)
+ . '</li></ul>'
+ . '<div class="buttons">'
+ . '<input type="submit" name="cancel" value="Cancel" class="button btn-cancel" />'
+ . '<input type="submit" name="submit" value="Apply" class="button btn-primary"/>'
+ . '</div>'
+ . '<input type="hidden" name="formUID" value="FilterEditor">'
+ . '</form>'
+ . '</div>';
+ }
+
+ protected function shorten($string, $length)
+ {
+ if (strlen($string) > $length) {
+ return substr($string, 0, $length) . '...';
+ }
+ return $string;
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return 'ERROR in FilterEditor: ' . $e->getMessage();
+ }
+ }
+}
diff --git a/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php
new file mode 100644
index 0000000..007a730
--- /dev/null
+++ b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php
@@ -0,0 +1,92 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget\ItemList;
+
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+
+class MigrationFileListItem extends BaseListItem
+{
+ use Translation;
+
+ /** @var DbMigrationStep Just for type hint */
+ protected $item;
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ if ($this->item->getLastState()) {
+ $visual->getAttributes()->add('class', 'upgrade-failed');
+ $visual->addHtml(new Icon('circle-xmark'));
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $scriptPath = $this->item->getScriptPath();
+ /** @var string $parentDirs */
+ $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema'));
+ $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1);
+
+ $title->addHtml(
+ new HtmlElement('span', null, Text::create($parentDirs)),
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'version']),
+ Text::create($this->item->getVersion() . '.sql')
+ )
+ );
+
+ if ($this->item->getLastState()) {
+ $title->addHtml(
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'upgrade-failed']),
+ Text::create($this->translate('Upgrade failed'))
+ )
+ );
+ }
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->addHtml($this->createTitle());
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ if ($this->item->getDescription()) {
+ $caption->addHtml(Text::create($this->item->getDescription()));
+ } else {
+ $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.'))));
+ }
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ if ($this->item->getLastState()) {
+ $footer->addHtml(
+ new HtmlElement(
+ 'section',
+ Attributes::create(['class' => 'caption']),
+ new HtmlElement('pre', null, new HtmlString(Html::escape($this->item->getLastState())))
+ )
+ );
+ }
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml($this->createHeader(), $this->createCaption());
+ }
+}
diff --git a/library/Icinga/Web/Widget/ItemList/MigrationList.php b/library/Icinga/Web/Widget/ItemList/MigrationList.php
new file mode 100644
index 0000000..43699d3
--- /dev/null
+++ b/library/Icinga/Web/Widget/ItemList/MigrationList.php
@@ -0,0 +1,133 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget\ItemList;
+
+use Generator;
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Application\MigrationManager;
+use Icinga\Forms\MigrationForm;
+use ipl\I18n\Translation;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Widget\EmptyStateBar;
+
+class MigrationList extends BaseItemList
+{
+ use Translation;
+
+ protected $baseAttributes = ['class' => 'item-list'];
+
+ /** @var Generator<DbMigrationHook> */
+ protected $data;
+
+ /** @var ?MigrationForm */
+ protected $migrationForm;
+
+ /** @var bool Whether to render minimal migration list items */
+ protected $minimal = true;
+
+ /**
+ * Create a new migration list
+ *
+ * @param Generator<DbMigrationHook>|array<DbMigrationStep|DbMigrationHook> $data
+ *
+ * @param ?MigrationForm $form
+ */
+ public function __construct($data, MigrationForm $form = null)
+ {
+ parent::__construct($data);
+
+ $this->migrationForm = $form;
+ }
+
+ /**
+ * Set whether to render minimal migration list items
+ *
+ * @param bool $minimal
+ *
+ * @return $this
+ */
+ public function setMinimal(bool $minimal): self
+ {
+ $this->minimal = $minimal;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to render minimal migration list items
+ *
+ * @return bool
+ */
+ public function isMinimal(): bool
+ {
+ return $this->minimal;
+ }
+
+ protected function getItemClass(): string
+ {
+ if ($this->isMinimal()) {
+ return MigrationListItem::class;
+ }
+
+ return MigrationFileListItem::class;
+ }
+
+ protected function assemble(): void
+ {
+ $itemClass = $this->getItemClass();
+ if (! $this->isMinimal()) {
+ $this->getAttributes()->add('class', 'file-list');
+ }
+
+ /** @var DbMigrationHook $data */
+ foreach ($this->data as $data) {
+ /** @var MigrationFileListItem|MigrationListItem $item */
+ $item = new $itemClass($data, $this);
+ if ($item instanceof MigrationListItem && $this->migrationForm) {
+ $migrateButton = $this->migrationForm->createElement(
+ 'submit',
+ sprintf('migrate-%s', $data->getModuleName()),
+ [
+ 'required' => false,
+ 'label' => $this->translate('Migrate'),
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'Migrate %d pending migration',
+ 'Migrate all %d pending migrations',
+ $data->count()
+ ),
+ $data->count()
+ )
+ ]
+ );
+
+ $mm = MigrationManager::instance();
+ if ($data->isModule() && $mm->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
+ $migrateButton->getAttributes()
+ ->set('disabled', true)
+ ->set(
+ 'title',
+ $this->translate(
+ 'Please apply all the pending migrations of Icinga Web first or use the apply all'
+ . ' button instead.'
+ )
+ );
+ }
+
+ $this->migrationForm->registerElement($migrateButton);
+
+ $item->setMigrateButton($migrateButton);
+ }
+
+ $this->addHtml($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar(t('No items found.')));
+ }
+ }
+}
diff --git a/library/Icinga/Web/Widget/ItemList/MigrationListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php
new file mode 100644
index 0000000..284ce4c
--- /dev/null
+++ b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php
@@ -0,0 +1,151 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget\ItemList;
+
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use Icinga\Application\Hook\DbMigrationHook;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\FormattedString;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Url;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use LogicException;
+
+class MigrationListItem extends BaseListItem
+{
+ use Translation;
+
+ /** @var ?FormElement */
+ protected $migrateButton;
+
+ /** @var DbMigrationHook Just for type hint */
+ protected $item;
+
+ /**
+ * Set a migration form of this list item
+ *
+ * @param FormElement $migrateButton
+ *
+ * @return $this
+ */
+ public function setMigrateButton(FormElement $migrateButton): self
+ {
+ $this->migrateButton = $migrateButton;
+
+ return $this;
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ FormattedString::create(
+ t('%s ', '<name>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->item->getName())
+ )
+ );
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ if ($this->migrateButton === null) {
+ throw new LogicException('Please set the migrate submit button beforehand');
+ }
+
+ $header->addHtml($this->createTitle());
+ $header->addHtml($this->migrateButton);
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ $migrations = $this->item->getMigrations();
+ /** @var DbMigrationStep $migration */
+ $migration = array_shift($migrations);
+ if ($migration->getLastState()) {
+ if ($migration->getDescription()) {
+ $caption->addHtml(Text::create($migration->getDescription()));
+ } else {
+ $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.'))));
+ }
+
+ $scriptPath = $migration->getScriptPath();
+ /** @var string $parentDirs */
+ $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema'));
+ $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1);
+
+ $title = new HtmlElement('div', Attributes::create(['class' => 'title']));
+ $title->addHtml(
+ new HtmlElement('span', null, Text::create($parentDirs)),
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'version']),
+ Text::create($migration->getVersion() . '.sql')
+ ),
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'upgrade-failed']),
+ Text::create($this->translate('Upgrade failed'))
+ )
+ );
+
+ $error = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'data-visible-height' => '58',
+ ]));
+ $error->addHtml(new HtmlElement('pre', null, new HtmlString(Html::escape($migration->getLastState()))));
+
+ $errorSection = new HtmlElement('div', Attributes::create(['class' => 'errors-section',]));
+ $errorSection->addHtml(
+ new HtmlElement('header', null, new Icon('circle-xmark', ['class' => 'status-icon']), $title),
+ $caption,
+ $error
+ );
+
+ $caption->prependWrapper($errorSection);
+ }
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ $footer->addHtml((new MigrationList($this->item->getLatestMigrations(3)))->setMinimal(false));
+ if ($this->item->count() > 3) {
+ $footer->addHtml(
+ new Link(
+ sprintf($this->translate('Show all %d migrations'), $this->item->count()),
+ Url::fromPath(
+ 'migrations/migration',
+ [DbMigrationHook::MIGRATION_PARAM => $this->item->getModuleName()]
+ ),
+ [
+ 'data-base-target' => '_next',
+ 'class' => 'show-more'
+ ]
+ )
+ );
+ }
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml($this->createHeader());
+ $caption = $this->createCaption();
+ if (! $caption->isEmpty()) {
+ $main->addHtml($caption);
+ }
+
+ $footer = $this->createFooter();
+ if ($footer) {
+ $main->addHtml($footer);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Widget/Limiter.php b/library/Icinga/Web/Widget/Limiter.php
new file mode 100644
index 0000000..d127aca
--- /dev/null
+++ b/library/Icinga/Web/Widget/Limiter.php
@@ -0,0 +1,54 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Forms\Control\LimiterControlForm;
+
+/**
+ * Limiter control widget
+ */
+class Limiter extends AbstractWidget
+{
+ /**
+ * Default limit for this instance
+ *
+ * @var int|null
+ */
+ protected $defaultLimit;
+
+ /**
+ * Get the default limit
+ *
+ * @return int|null
+ */
+ public function getDefaultLimit()
+ {
+ return $this->defaultLimit;
+ }
+
+ /**
+ * Set the default limit
+ *
+ * @param int $defaultLimit
+ *
+ * @return $this
+ */
+ public function setDefaultLimit($defaultLimit)
+ {
+ $this->defaultLimit = (int) $defaultLimit;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ $control = new LimiterControlForm();
+ $control
+ ->setDefaultLimit($this->defaultLimit)
+ ->handleRequest();
+ return (string)$control;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Paginator.php b/library/Icinga/Web/Widget/Paginator.php
new file mode 100644
index 0000000..5f3ef04
--- /dev/null
+++ b/library/Icinga/Web/Widget/Paginator.php
@@ -0,0 +1,167 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Data\Paginatable;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Paginator
+ */
+class Paginator extends AbstractWidget
+{
+ /**
+ * The query the paginator widget is created for
+ *
+ * @var Paginatable
+ */
+ protected $query;
+
+ /**
+ * The view script in use
+ *
+ * @var string|array
+ */
+ protected $viewScript = array('mixedPagination.phtml', 'default');
+
+ /**
+ * Set the query to create the paginator widget for
+ *
+ * @param Paginatable $query
+ *
+ * @return $this
+ */
+ public function setQuery(Paginatable $query)
+ {
+ $this->query = $query;
+ return $this;
+ }
+
+ /**
+ * Set the view script to use
+ *
+ * @param string|array $script
+ *
+ * @return $this
+ */
+ public function setViewScript($script)
+ {
+ $this->viewScript = $script;
+ return $this;
+ }
+
+ /**
+ * Render this paginator
+ */
+ public function render()
+ {
+ if ($this->query === null) {
+ throw new ProgrammingError('Need a query to create the paginator widget for');
+ }
+
+ $itemCountPerPage = $this->query->getLimit();
+ if (! $itemCountPerPage) {
+ return ''; // No pagination required
+ }
+
+ $totalItemCount = count($this->query);
+ $pageCount = (int) ceil($totalItemCount / $itemCountPerPage);
+ $currentPage = $this->query->hasOffset() ? ($this->query->getOffset() / $itemCountPerPage) + 1 : 1;
+ $pagesInRange = $this->getPages($pageCount, $currentPage);
+ $variables = array(
+ 'totalItemCount' => $totalItemCount,
+ 'pageCount' => $pageCount,
+ 'itemCountPerPage' => $itemCountPerPage,
+ 'first' => 1,
+ 'current' => $currentPage,
+ 'last' => $pageCount,
+ 'pagesInRange' => $pagesInRange,
+ 'firstPageInRange' => min($pagesInRange),
+ 'lastPageInRange' => max($pagesInRange)
+ );
+
+ if ($currentPage > 1) {
+ $variables['previous'] = $currentPage - 1;
+ }
+
+ if ($currentPage < $pageCount) {
+ $variables['next'] = $currentPage + 1;
+ }
+
+ if (is_array($this->viewScript)) {
+ if ($this->viewScript[1] !== null) {
+ return $this->view()->partial($this->viewScript[0], $this->viewScript[1], $variables);
+ }
+
+ return $this->view()->partial($this->viewScript[0], $variables);
+ }
+
+ return $this->view()->partial($this->viewScript, $variables);
+ }
+
+ /**
+ * Returns an array of "local" pages given the page count and current page number
+ *
+ * @return array
+ */
+ protected function getPages($pageCount, $currentPage)
+ {
+ $range = array();
+
+ if ($pageCount < 10) {
+ // Show all pages if we have less than 10
+ for ($i = 1; $i < 10; $i++) {
+ if ($i > $pageCount) {
+ break;
+ }
+
+ $range[$i] = $i;
+ }
+ } else {
+ // More than 10 pages:
+ foreach (array(1, 2) as $i) {
+ $range[$i] = $i;
+ }
+
+ if ($currentPage < 6) {
+ // We are on page 1-5 from
+ for ($i = 1; $i <= 7; $i++) {
+ $range[$i] = $i;
+ }
+ } else {
+ // Current page > 5
+ $range[] = '...';
+
+ if (($pageCount - $currentPage) < 5) {
+ // Less than 5 pages left
+ $start = 5 - ($pageCount - $currentPage);
+ } else {
+ $start = 1;
+ }
+
+ for ($i = $currentPage - $start; $i < ($currentPage + (4 - $start)); $i++) {
+ if ($i > $pageCount) {
+ break;
+ }
+
+ $range[$i] = $i;
+ }
+ }
+
+ if ($currentPage < ($pageCount - 2)) {
+ $range[] = '...';
+ }
+
+ foreach (array($pageCount - 1, $pageCount) as $i) {
+ $range[$i] = $i;
+ }
+ }
+
+ if (empty($range)) {
+ $range[] = 1;
+ }
+
+ return $range;
+ }
+}
diff --git a/library/Icinga/Web/Widget/SearchDashboard.php b/library/Icinga/Web/Widget/SearchDashboard.php
new file mode 100644
index 0000000..1ce4c46
--- /dev/null
+++ b/library/Icinga/Web/Widget/SearchDashboard.php
@@ -0,0 +1,111 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Zend_Controller_Action_Exception;
+use Icinga\Application\Icinga;
+use Icinga\Web\Url;
+
+/**
+ * Class SearchDashboard display multiple search views on a single search page
+ */
+class SearchDashboard extends Dashboard
+{
+ /**
+ * Name for the search pane
+ *
+ * @var string
+ */
+ const SEARCH_PANE = 'search';
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTabs()
+ {
+ if ($this->tabs === null) {
+ $this->tabs = new Tabs();
+ $this->tabs->add(
+ 'search',
+ array(
+ 'title' => t('Show Search', 'dashboard.pane.tooltip'),
+ 'label' => t('Search'),
+ 'url' => Url::fromRequest()
+ )
+ );
+ }
+ return $this->tabs;
+ }
+
+ /**
+ * Load all available search dashlets from modules
+ *
+ * @param string $searchString
+ *
+ * @return $this
+ */
+ public function search($searchString = '')
+ {
+ $pane = $this->createPane(self::SEARCH_PANE)->getPane(self::SEARCH_PANE)->setTitle(t('Search'));
+ $this->activate(self::SEARCH_PANE);
+
+ $manager = Icinga::app()->getModuleManager();
+ $searchUrls = array();
+
+ foreach ($manager->getLoadedModules() as $module) {
+ if ($this->getUser()->can($manager::MODULE_PERMISSION_NS . $module->getName())) {
+ $moduleSearchUrls = $module->getSearchUrls();
+ if (! empty($moduleSearchUrls)) {
+ if ($searchString === '') {
+ $pane->add(t('Ready to search'), 'search/hint');
+ return $this;
+ }
+ $searchUrls = array_merge($searchUrls, $moduleSearchUrls);
+ }
+ }
+ }
+
+ usort($searchUrls, array($this, 'compareSearchUrls'));
+
+ foreach (array_reverse($searchUrls) as $searchUrl) {
+ $pane->createDashlet(
+ $searchUrl->title . ': ' . $searchString,
+ Url::fromPath($searchUrl->url, array('q' => $searchString))
+ )->setProgressLabel(t('Searching'));
+ }
+
+ return $this;
+ }
+
+ /**
+ * Renders the output
+ *
+ * @return string
+ *
+ * @throws Zend_Controller_Action_Exception
+ */
+ public function render()
+ {
+ if (! $this->getPane(self::SEARCH_PANE)->hasDashlets()) {
+ throw new Zend_Controller_Action_Exception(t('Page not found'), 404);
+ }
+ return parent::render();
+ }
+
+ /**
+ * Compare search URLs based on their priority
+ *
+ * @param object $a
+ * @param object $b
+ *
+ * @return int
+ */
+ private function compareSearchUrls($a, $b)
+ {
+ if ($a->priority === $b->priority) {
+ return 0;
+ }
+ return ($a->priority < $b->priority) ? -1 : 1;
+ }
+}
diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php
new file mode 100644
index 0000000..470518c
--- /dev/null
+++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php
@@ -0,0 +1,200 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Icinga;
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\Control\SearchBar\Suggestions;
+use ipl\Web\Url;
+
+class SingleValueSearchControl extends Form
+{
+ /** @var string */
+ const DEFAULT_SEARCH_PARAMETER = 'q';
+
+ protected $defaultAttributes = ['class' => 'icinga-controls inline'];
+
+ /** @var string */
+ protected $searchParameter = self::DEFAULT_SEARCH_PARAMETER;
+
+ /** @var string */
+ protected $inputLabel;
+
+ /** @var string */
+ protected $submitLabel;
+
+ /** @var Url */
+ protected $suggestionUrl;
+
+ /** @var array */
+ protected $metaDataNames;
+
+ /**
+ * Set the search parameter to use
+ *
+ * @param string $name
+ * @return $this
+ */
+ public function setSearchParameter($name)
+ {
+ $this->searchParameter = $name;
+
+ return $this;
+ }
+
+ /**
+ * Set the input's label
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setInputLabel($label)
+ {
+ $this->inputLabel = $label;
+
+ return $this;
+ }
+
+ /**
+ * Set the submit button's label
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+
+ return $this;
+ }
+
+ /**
+ * Set the suggestion url
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setSuggestionUrl(Url $url)
+ {
+ $this->suggestionUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Set names for which hidden meta data elements should be created
+ *
+ * @param string ...$names
+ *
+ * @return $this
+ */
+ public function setMetaDataNames(...$names)
+ {
+ $this->metaDataNames = $names;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $suggestionsId = Icinga::app()->getRequest()->protectId('single-value-suggestions');
+
+ $this->addElement(
+ 'text',
+ $this->searchParameter,
+ [
+ 'required' => true,
+ 'minlength' => 1,
+ 'autocomplete' => 'off',
+ 'class' => 'search',
+ 'data-enrichment-type' => 'completion',
+ 'data-term-suggestions' => '#' . $suggestionsId,
+ 'data-suggest-url' => $this->suggestionUrl,
+ 'placeholder' => $this->inputLabel
+ ]
+ );
+
+ if (! empty($this->metaDataNames)) {
+ $fieldset = new HtmlElement('fieldset');
+ foreach ($this->metaDataNames as $name) {
+ $hiddenElement = $this->createElement('hidden', $this->searchParameter . '-' . $name);
+ $this->registerElement($hiddenElement);
+ $fieldset->addHtml($hiddenElement);
+ }
+
+ $this->getElement($this->searchParameter)->prependWrapper($fieldset);
+ }
+
+ $this->addElement(
+ 'submit',
+ 'btn_sumit',
+ [
+ 'label' => $this->submitLabel,
+ 'class' => 'btn-primary'
+ ]
+ );
+
+ $this->add(HtmlElement::create('div', [
+ 'id' => $suggestionsId,
+ 'class' => 'search-suggestions'
+ ]));
+ }
+
+ /**
+ * Create a list of search suggestions based on the given groups
+ *
+ * @param array $groups
+ *
+ * @return HtmlElement
+ */
+ public static function createSuggestions(array $groups)
+ {
+ $ul = new HtmlElement('ul');
+ foreach ($groups as list($name, $entries)) {
+ if ($name) {
+ if ($entries === false) {
+ $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [
+ HtmlElement::create('em', null, t('Can\'t search:')),
+ $name
+ ]));
+ continue;
+ } elseif (empty($entries)) {
+ $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [
+ HtmlElement::create('em', null, t('No results:')),
+ $name
+ ]));
+ continue;
+ } else {
+ $ul->addHtml(
+ HtmlElement::create('li', ['class' => Suggestions::SUGGESTION_TITLE_CLASS], $name)
+ );
+ }
+ }
+
+ $index = 0;
+ foreach ($entries as list($label, $metaData)) {
+ $attributes = [
+ 'value' => $label,
+ 'type' => 'button',
+ 'tabindex' => -1
+ ];
+ foreach ($metaData as $key => $value) {
+ $attributes['data-' . $key] = $value;
+ }
+
+ $liAtrs = ['class' => $index === 0 ? 'default' : null];
+ $ul->addHtml(new HtmlElement('li', Attributes::create($liAtrs), new InputElement(null, $attributes)));
+ $index++;
+ }
+ }
+
+ return $ul;
+ }
+}
diff --git a/library/Icinga/Web/Widget/SortBox.php b/library/Icinga/Web/Widget/SortBox.php
new file mode 100644
index 0000000..72b6f58
--- /dev/null
+++ b/library/Icinga/Web/Widget/SortBox.php
@@ -0,0 +1,260 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Icinga;
+use Icinga\Data\Sortable;
+use Icinga\Data\SortRules;
+use Icinga\Web\Form;
+use Icinga\Web\Request;
+
+/**
+ * SortBox widget
+ *
+ * The "SortBox" Widget allows you to create a generic sort input for sortable views. It automatically creates a select
+ * box with all sort options and a dropbox with the sort direction. It also handles automatic submission of sorting
+ * changes and draws an additional submit button when JavaScript is disabled.
+ *
+ * The constructor takes a string for the component name and an array containing the select options, where the key is
+ * the value to be submitted and the value is the label that will be shown. You then should call setRequest in order
+ * to make sure the form is correctly populated when a request with a sort parameter is being made.
+ *
+ * Call setQuery in case you'll do not want to handle URL parameters manually, but to automatically apply the user's
+ * chosen sort rules on the given sortable query. This will also allow the SortBox to display the user the correct
+ * default sort rules if the given query provides already some sort rules.
+ */
+class SortBox extends AbstractWidget
+{
+ /**
+ * An array containing all sort columns with their associated labels
+ *
+ * @var array
+ */
+ protected $sortFields;
+
+ /**
+ * An array containing default sort directions for specific columns
+ *
+ * The first entry will be used as default sort column.
+ *
+ * @var array
+ */
+ protected $sortDefaults;
+
+ /**
+ * The name used to uniquely identfy the forms being created
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The request to fetch sort rules from
+ *
+ * @var Request
+ */
+ protected $request;
+
+ /**
+ * The query to apply sort rules on
+ *
+ * @var Sortable
+ */
+ protected $query;
+
+ /**
+ * Create a SortBox with the entries from $sortFields
+ *
+ * @param string $name The name for the SortBox
+ * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox
+ * @param array $sortDefaults An array containing default sort directions for specific columns
+ */
+ public function __construct($name, array $sortFields, array $sortDefaults = null)
+ {
+ $this->name = $name;
+ $this->sortFields = $sortFields;
+ $this->sortDefaults = $sortDefaults;
+ }
+
+ /**
+ * Create a SortBox
+ *
+ * @param string $name The name for the SortBox
+ * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox
+ * @param array $sortDefaults An array containing default sort directions for specific columns
+ *
+ * @return SortBox
+ */
+ public static function create($name, array $sortFields, array $sortDefaults = null)
+ {
+ return new static($name, $sortFields, $sortDefaults);
+ }
+
+ /**
+ * Set the request to fetch sort rules from
+ *
+ * @param Request $request
+ *
+ * @return $this
+ */
+ public function setRequest($request)
+ {
+ $this->request = $request;
+ return $this;
+ }
+
+ /**
+ * Set the query to apply sort rules on
+ *
+ * @param Sortable $query
+ *
+ * @return $this
+ */
+ public function setQuery(Sortable $query)
+ {
+ $this->query = $query;
+ return $this;
+ }
+
+ /**
+ * Return the default sort rule for the query
+ *
+ * @param string $column An optional column
+ *
+ * @return array An array of two values: $column, $direction
+ */
+ protected function getSortDefaults($column = null)
+ {
+ $direction = null;
+ if (! empty($this->sortDefaults) && ($column === null || isset($this->sortDefaults[$column]))) {
+ if ($column === null) {
+ reset($this->sortDefaults);
+ $column = key($this->sortDefaults);
+ }
+
+ $direction = $this->sortDefaults[$column];
+ } elseif ($this->query !== null && $this->query instanceof SortRules) {
+ $sortRules = $this->query->getSortRules();
+ if ($column === null) {
+ $column = key($sortRules);
+ }
+
+ if ($column !== null && isset($sortRules[$column]['order'])) {
+ $direction = strtoupper($sortRules[$column]['order']) === Sortable::SORT_DESC ? 'desc' : 'asc';
+ }
+ } elseif ($column === null) {
+ reset($this->sortFields);
+ $column = key($this->sortFields);
+ }
+
+ return array($column, $direction);
+ }
+
+ /**
+ * Apply the sort rules from the given or current request on the query
+ *
+ * @param Request $request
+ *
+ * @return $this
+ */
+ public function handleRequest(Request $request = null)
+ {
+ if ($this->query !== null) {
+ if ($request === null) {
+ $request = Icinga::app()->getRequest();
+ }
+
+ if (! ($sort = $request->getParam('sort'))) {
+ list($sort, $dir) = $this->getSortDefaults();
+ } else {
+ list($_, $dir) = $this->getSortDefaults($sort);
+ }
+
+ $this->query->order($sort, $request->getParam('dir', $dir));
+ }
+
+ return $this;
+ }
+
+ /**
+ * Render this SortBox as HTML
+ *
+ * @return string
+ */
+ public function render()
+ {
+ $columnForm = new Form();
+ $columnForm->setTokenDisabled();
+ $columnForm->setName($this->name . '-column');
+ $columnForm->setAttrib('class', 'icinga-controls inline');
+ $columnForm->addElement(
+ 'select',
+ 'sort',
+ array(
+ 'autosubmit' => true,
+ 'label' => $this->view()->translate('Sort by'),
+ 'multiOptions' => $this->sortFields,
+ 'decorators' => array(
+ array('ViewHelper'),
+ array('Label')
+ )
+ )
+ );
+
+ $column = null;
+ if ($this->request) {
+ $url = $this->request->getUrl();
+ if ($url->hasParam('sort')) {
+ $column = $url->getParam('sort');
+
+ if ($url->hasParam('dir')) {
+ $direction = $url->getParam('dir');
+ } else {
+ list($_, $direction) = $this->getSortDefaults($column);
+ }
+ } elseif ($url->hasParam('dir')) {
+ $direction = $url->getParam('dir');
+ list($column, $_) = $this->getSortDefaults();
+ }
+ }
+
+ if ($column === null) {
+ list($column, $direction) = $this->getSortDefaults();
+ }
+
+ // TODO(el): ToggleButton :)
+ $toggle = array('asc' => 'sort-name-down', 'desc' => 'sort-name-up');
+ unset($toggle[isset($direction) ? strtolower($direction) : 'asc']);
+ $newDirection = key($toggle);
+ $icon = current($toggle);
+
+ $orderForm = new Form();
+ $orderForm->setTokenDisabled();
+ $orderForm->setName($this->name . '-order');
+ $orderForm->setAttrib('class', 'inline sort-direction-control');
+ $orderForm->addElement(
+ 'hidden',
+ 'dir'
+ );
+ $orderForm->addElement(
+ 'button',
+ 'btn_submit',
+ array(
+ 'ignore' => true,
+ 'type' => 'submit',
+ 'label' => $this->view()->icon($icon),
+ 'decorators' => array('ViewHelper'),
+ 'escape' => false,
+ 'class' => 'link-button spinner',
+ 'value' => 'submit',
+ 'title' => t('Change sort direction'),
+ )
+ );
+
+
+ $columnForm->populate(array('sort' => $column));
+ $orderForm->populate(array('dir' => $newDirection));
+ return '<div class="sort-control">' . $columnForm . $orderForm . '</div>';
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tab.php b/library/Icinga/Web/Widget/Tab.php
new file mode 100644
index 0000000..a367f00
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tab.php
@@ -0,0 +1,323 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Web\Url;
+
+/**
+ * A single tab, usually used through the tabs widget
+ *
+ * Will generate an &lt;li&gt; list item, with an optional link and icon
+ *
+ * @property string $name Tab identifier
+ * @property string $title Tab title
+ * @property string $icon Icon URL, preferrably relative to the Icinga
+ * base URL
+ * @property string|URL $url Action URL, preferrably relative to the Icinga
+ * base URL
+ * @property string $urlParams Action URL Parameters
+ *
+ */
+class Tab extends AbstractWidget
+{
+ /**
+ * Whether this tab is currently active
+ *
+ * @var bool
+ */
+ private $active = false;
+
+ /**
+ * Default values for widget properties
+ *
+ * @var array
+ */
+ private $name = null;
+
+ /**
+ * The title displayed for this tab
+ *
+ * @var string
+ */
+ private $title = '';
+
+ /**
+ * The label displayed for this tab
+ *
+ * @var string
+ */
+ private $label = '';
+
+ /**
+ * The Url this tab points to
+ *
+ * @var Url|null
+ */
+ private $url = null;
+
+ /**
+ * The parameters for this tab's Url
+ *
+ * @var array
+ */
+ private $urlParams = array();
+
+ /**
+ * The icon image to use for this tab or null if none
+ *
+ * @var string|null
+ */
+ private $icon = null;
+
+ /**
+ * The icon class to use if $icon is null
+ *
+ * @var string|null
+ */
+ private $iconCls = null;
+
+ /**
+ * Additional a tag attributes
+ *
+ * @var array
+ */
+ private $tagParams;
+
+ /**
+ * Whether to open the link target on a new page
+ *
+ * @var boolean
+ */
+ private $targetBlank = false;
+
+ /**
+ * Data base target that determines if the link will be opened in a side-bar or in the main container
+ *
+ * @var null
+ */
+ private $baseTarget = null;
+
+ /**
+ * Sets an icon image for this tab
+ *
+ * @param string $icon The url of the image to use
+ */
+ public function setIcon($icon)
+ {
+ if (is_string($icon) && strpos($icon, '.') !== false) {
+ $icon = Url::fromPath($icon);
+ }
+ $this->icon = $icon;
+ }
+
+ /**
+ * Set's an icon class that will be used in an <i> tag if no icon image is set
+ *
+ * @param string $iconCls The CSS class of the icon to use
+ */
+ public function setIconCls($iconCls)
+ {
+ $this->iconCls = $iconCls;
+ }
+
+ /**
+ * @param mixed $name
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the tab label
+ *
+ * @param string $label
+ */
+ public function setLabel($label)
+ {
+ $this->label = $label;
+ }
+
+ /**
+ * Get the tab label
+ *
+ * @return string
+ */
+ public function getLabel()
+ {
+ if (! $this->label) {
+ return $this->title;
+ }
+
+ return $this->label;
+ }
+
+ /**
+ * @param mixed $title
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ }
+
+ /**
+ * Set the Url this tab points to
+ *
+ * @param string|Url $url The Url to use for this tab
+ */
+ public function setUrl($url)
+ {
+ if (is_string($url)) {
+ $url = Url::fromPath($url);
+ }
+ $this->url = $url;
+ }
+
+ /**
+ * Get the tab's target URL
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the parameters to be set for this tabs Url
+ *
+ * @param array $url The Url parameters to set
+ */
+ public function setUrlParams(array $urlParams)
+ {
+ $this->urlParams = $urlParams;
+ }
+
+ /**
+ * Set additional a tag attributes
+ *
+ * @param array $tagParams
+ */
+ public function setTagParams(array $tagParams)
+ {
+ $this->tagParams = $tagParams;
+ }
+
+ public function setTargetBlank($value = true)
+ {
+ $this->targetBlank = $value;
+ }
+
+ public function setBaseTarget($value)
+ {
+ $this->baseTarget = $value;
+ }
+
+ /**
+ * Create a new Tab with the given properties
+ *
+ * Allowed properties are all properties for which a setter exists
+ *
+ * @param array $properties An array of properties
+ */
+ public function __construct(array $properties = array())
+ {
+ foreach ($properties as $name => $value) {
+ $setter = 'set' . ucfirst($name);
+ if (method_exists($this, $setter)) {
+ $this->$setter($value);
+ }
+ }
+ }
+
+ /**
+ * Set this tab active (default) or inactive
+ *
+ * This is usually done through the tabs container widget, therefore it
+ * is not a good idea to directly call this function
+ *
+ * @param bool $active Whether the tab should be active
+ *
+ * @return $this
+ */
+ public function setActive($active = true)
+ {
+ $this->active = (bool) $active;
+ return $this;
+ }
+
+ /**
+ * @see Widget::render()
+ */
+ public function render()
+ {
+ $view = $this->view();
+ $classes = array();
+ if ($this->active) {
+ $classes[] = 'active';
+ }
+
+ $caption = $view->escape($this->getLabel());
+ $tagParams = $this->tagParams;
+ if ($this->targetBlank) {
+ // add warning to links that open in new tabs to improve accessibility, as recommended by WCAG20 G201
+ $caption .= '<span class="info-box display-on-hover"> opens in new window </span>';
+ $tagParams['target'] ='_blank';
+ }
+
+ if ($this->title) {
+ if ($tagParams !== null) {
+ $tagParams['title'] = $this->title;
+ $tagParams['aria-label'] = $this->title;
+ } else {
+ $tagParams = array(
+ 'title' => $this->title,
+ 'aria-label' => $this->title
+ );
+ }
+ }
+
+ if ($this->baseTarget !== null) {
+ $tagParams['data-base-target'] = $this->baseTarget;
+ }
+
+ if ($this->icon !== null) {
+ if (strpos($this->icon, '.') === false) {
+ $caption = $view->icon($this->icon) . $caption;
+ } else {
+ $caption = $view->img($this->icon, null, array('class' => 'icon')) . $caption;
+ }
+ }
+
+ if ($this->url !== null) {
+ $this->url->overwriteParams($this->urlParams);
+
+ if ($tagParams !== null) {
+ $params = $view->propertiesToString($tagParams);
+ } else {
+ $params = '';
+ }
+
+ $tab = sprintf(
+ '<a href="%s"%s>%s</a>',
+ $this->view()->escape($this->url->getAbsoluteUrl()),
+ $params,
+ $caption
+ );
+ } else {
+ $tab = $caption;
+ }
+
+ $class = empty($classes) ? '' : sprintf(' class="%s"', implode(' ', $classes));
+ return '<li ' . $class . '>' . $tab . "</li>\n";
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardAction.php b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php
new file mode 100644
index 0000000..a3e6c43
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php
@@ -0,0 +1,35 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension that allows to add the current URL to a dashboard
+ *
+ * Displayed as a dropdown field in the tabs
+ */
+class DashboardAction implements Tabextension
+{
+ /**
+ * Applies the dashboard actions to the provided tabset
+ *
+ * @param Tabs $tabs The tabs object to extend with
+ */
+ public function apply(Tabs $tabs)
+ {
+ $tabs->addAsDropdown(
+ 'dashboard',
+ array(
+ 'icon' => 'dashboard',
+ 'label' => t('Add To Dashboard'),
+ 'url' => Url::fromPath('dashboard/new-dashlet'),
+ 'urlParams' => array(
+ 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl())
+ )
+ )
+ );
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php
new file mode 100644
index 0000000..fc7412a
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Dashboard settings
+ */
+class DashboardSettings implements Tabextension
+{
+ /**
+ * Apply this tabextension to the provided tabs
+ *
+ * @param Tabs $tabs The tabbar to modify
+ */
+ public function apply(Tabs $tabs)
+ {
+ $tabs->addAsDropdown(
+ 'dashboard_add',
+ array(
+ 'icon' => 'dashboard',
+ 'label' => t('Add Dashlet'),
+ 'url' => Url::fromPath('dashboard/new-dashlet')
+ )
+ );
+
+ $tabs->addAsDropdown(
+ 'dashboard_settings',
+ array(
+ 'icon' => 'dashboard',
+ 'label' => t('Settings'),
+ 'url' => Url::fromPath('dashboard/settings')
+ )
+ );
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/MenuAction.php b/library/Icinga/Web/Widget/Tabextension/MenuAction.php
new file mode 100644
index 0000000..d713892
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/MenuAction.php
@@ -0,0 +1,35 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension that allows to add the current URL as menu entry
+ *
+ * Displayed as a dropdown field in the tabs
+ */
+class MenuAction implements Tabextension
+{
+ /**
+ * Applies the menu actions to the provided tabset
+ *
+ * @param Tabs $tabs The tabs object to extend with
+ */
+ public function apply(Tabs $tabs)
+ {
+ $tabs->addAsDropdown(
+ 'menu-entry',
+ array(
+ 'icon' => 'menu',
+ 'label' => t('Add To Menu'),
+ 'url' => Url::fromPath('navigation/add'),
+ 'urlParams' => array(
+ 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl())
+ )
+ )
+ );
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/OutputFormat.php b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php
new file mode 100644
index 0000000..d5d83af
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php
@@ -0,0 +1,114 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Application\Platform;
+use Icinga\Application\Hook;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tab;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension that offers different output formats for the user in the dropdown area
+ */
+class OutputFormat implements Tabextension
+{
+ /**
+ * PDF output type
+ */
+ const TYPE_PDF = 'pdf';
+
+ /**
+ * JSON output type
+ */
+ const TYPE_JSON = 'json';
+
+ /**
+ * CSV output type
+ */
+ const TYPE_CSV = 'csv';
+
+ /**
+ * An array of tabs to be added to the dropdown area
+ *
+ * @var array
+ */
+ private $tabs = array();
+
+ /**
+ * Create a new OutputFormat extender
+ *
+ * In general, it's assumed that all types are supported when an outputFormat extension
+ * is added, so this class offers to remove specific types instead of adding ones
+ *
+ * @param array $disabled An array of output types to <b>not</b> show.
+ */
+ public function __construct(array $disabled = array())
+ {
+ foreach ($this->getSupportedTypes() as $type => $tabConfig) {
+ if (!in_array($type, $disabled)) {
+ $tabConfig['url'] = Url::fromRequest();
+ $tab = new Tab($tabConfig);
+ $tab->setTargetBlank();
+ $this->tabs[] = $tab;
+ }
+ }
+ }
+
+ /**
+ * Applies the format selectio to the provided tabset
+ *
+ * @param Tabs $tabs The tabs object to extend with
+ *
+ * @see Tabextension::apply()
+ */
+ public function apply(Tabs $tabs)
+ {
+ foreach ($this->tabs as $tab) {
+ $tabs->addAsDropdown($tab->getName(), $tab);
+ }
+ }
+
+ /**
+ * Return an array containing the tab definitions for all supported types
+ *
+ * Using array_keys on this array or isset allows to check whether a
+ * requested type is supported
+ *
+ * @return array
+ */
+ public function getSupportedTypes()
+ {
+ $supportedTypes = array();
+
+ $pdfexport = Hook::has('Pdfexport');
+
+ if ($pdfexport || Platform::extensionLoaded('gd')) {
+ $supportedTypes[self::TYPE_PDF] = array(
+ 'name' => 'pdf',
+ 'label' => 'PDF',
+ 'icon' => 'file-pdf',
+ 'urlParams' => array('format' => 'pdf'),
+ );
+ }
+
+ $supportedTypes[self::TYPE_CSV] = array(
+ 'name' => 'csv',
+ 'label' => 'CSV',
+ 'icon' => 'file-excel',
+ 'urlParams' => array('format' => 'csv')
+ );
+
+ if (Platform::extensionLoaded('json')) {
+ $supportedTypes[self::TYPE_JSON] = array(
+ 'name' => 'json',
+ 'label' => 'JSON',
+ 'icon' => 'doc-text',
+ 'urlParams' => array('format' => 'json')
+ );
+ }
+
+ return $supportedTypes;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/Tabextension.php b/library/Icinga/Web/Widget/Tabextension/Tabextension.php
new file mode 100644
index 0000000..ea49c4b
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/Tabextension.php
@@ -0,0 +1,25 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension interface that allows to extend a tabbar with reusable components
+ *
+ * Tabs can be either extended by creating a `Tabextension` and calling the `apply()` method
+ * or by calling the `\Icinga\Web\Widget\Tabs` `extend()` method and providing
+ * a tab extension
+ *
+ * @see \Icinga\Web\Widget\Tabs::extend()
+ */
+interface Tabextension
+{
+ /**
+ * Apply this tabextension to the provided tabs
+ *
+ * @param Tabs $tabs The tabbar to modify
+ */
+ public function apply(Tabs $tabs);
+}
diff --git a/library/Icinga/Web/Widget/Tabs.php b/library/Icinga/Web/Widget/Tabs.php
new file mode 100644
index 0000000..9efa423
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabs.php
@@ -0,0 +1,453 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Exception;
+use Icinga\Exception\Http\HttpNotFoundException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabextension\Tabextension;
+use Icinga\Application\Icinga;
+use Countable;
+
+/**
+ * Navigation tab widget
+ */
+class Tabs extends AbstractWidget implements Countable
+{
+ /**
+ * Template used for the base tabs
+ *
+ * @var string
+ */
+ private $baseTpl = <<< 'EOT'
+<ul class="tabs primary-nav nav">
+ {TABS}
+ {DROPDOWN}
+ {REFRESH}
+ {CLOSE}
+</ul>
+EOT;
+
+ /**
+ * Template used for the tabs dropdown
+ *
+ * @var string
+ */
+ private $dropdownTpl = <<< 'EOT'
+<li class="dropdown-nav-item">
+ <a href="#" class="dropdown-toggle" title="{TITLE}" aria-label="{TITLE}">
+ <i aria-hidden="true" class="icon-down-open"></i>
+ </a>
+ <ul class="nav">
+ {TABS}
+ </ul>
+</li>
+EOT;
+
+ /**
+ * Template used for the close-button
+ *
+ * @var string
+ */
+ private $closeTpl = <<< 'EOT'
+<li class="close-container-btn">
+ <a href="#" title="{TITLE}" aria-label="{TITLE}" class="close-container-control">
+ <i aria-hidden="true" class="icon-cancel"></i>
+ </a>
+</li>
+EOT;
+
+ /**
+ * Template used for the refresh icon
+ *
+ * @var string
+ */
+ private $refreshTpl = <<< 'EOT'
+<li>
+ <a class="refresh-container-control spinner" href="{URL}" title="{TITLE}" aria-label="{LABEL}">
+ <i aria-hidden="true" class="icon-cw"></i>
+ </a>
+</li>
+EOT;
+
+ /**
+ * This is where single tabs added to this container will be stored
+ *
+ * @var array
+ */
+ private $tabs = array();
+
+ /**
+ * The name of the currently activated tab
+ *
+ * @var string
+ */
+ private $active;
+
+ /**
+ * Array of tab names which should be displayed in a dropdown
+ *
+ * @var array
+ */
+ private $dropdownTabs = array();
+
+ /**
+ * Whether only the close-button should by rendered for this tab
+ *
+ * @var bool
+ */
+ private $closeButtonOnly = false;
+
+ /**
+ * Whether the tabs should contain a close-button
+ *
+ * @var bool
+ */
+ private $closeTab = true;
+
+ /**
+ * CSS class name(s) for the &lt;ul&gt; element
+ *
+ * @var string
+ */
+ private $tab_class;
+
+ /**
+ * Set whether the current tab is closable
+ */
+ public function hideCloseButton()
+ {
+ $this->closeTab = false;
+ }
+
+ /**
+ * Activate the tab with the given name
+ *
+ * If another tab is currently active it will be deactivated
+ *
+ * @param string $name Name of the tab going to be activated
+ *
+ * @return $this
+ *
+ * @throws HttpNotFoundException When the tab w/ the given name does not exist
+ *
+ */
+ public function activate($name)
+ {
+ if (! $this->has($name)) {
+ throw new HttpNotFoundException('Can\'t activate tab %s. Tab does not exist', $name);
+ }
+
+ if ($this->active !== null) {
+ $this->tabs[$this->active]->setActive(false);
+ }
+ $this->get($name)->setActive();
+ $this->active = $name;
+
+ return $this;
+ }
+
+ /**
+ * Return the name of the active tab
+ *
+ * @return string
+ */
+ public function getActiveName()
+ {
+ return $this->active;
+ }
+
+ /**
+ * Set the CSS class name(s) for the &lt;ul&gt; element
+ *
+ * @param string $name CSS class name(s)
+ *
+ * @return $this
+ */
+ public function setClass($name)
+ {
+ $this->tab_class = $name;
+ return $this;
+ }
+
+ /**
+ * Whether the given tab name exists
+ *
+ * @param string $name Tab name
+ *
+ * @return bool
+ */
+ public function has($name)
+ {
+ return array_key_exists($name, $this->tabs);
+ }
+
+ /**
+ * Whether the given tab name exists
+ *
+ * @param string $name The tab you're interested in
+ *
+ * @return Tab
+ *
+ * @throws ProgrammingError When the given tab name doesn't exist
+ */
+ public function get($name)
+ {
+ if (!$this->has($name)) {
+ return null;
+ }
+ return $this->tabs[$name];
+ }
+
+ /**
+ * Add a new tab
+ *
+ * A unique tab name is required, the Tab itself can either be an array
+ * with tab properties or an instance of an existing Tab
+ *
+ * @param string $name The new tab name
+ * @param array|Tab $tab The tab itself of its properties
+ *
+ * @return $this
+ *
+ * @throws ProgrammingError When the tab name already exists
+ */
+ public function add($name, $tab)
+ {
+ if ($this->has($name)) {
+ throw new ProgrammingError(
+ 'Cannot add a tab named "%s" twice"',
+ $name
+ );
+ }
+ return $this->set($name, $tab);
+ }
+
+ /**
+ * Set a tab
+ *
+ * A unique tab name is required, will be replaced in case it already
+ * exists. The tab can either be an array with tab properties or an instance
+ * of an existing Tab
+ *
+ * @param string $name The new tab name
+ * @param array|Tab $tab The tab itself of its properties
+ *
+ * @return $this
+ */
+ public function set($name, $tab)
+ {
+ if ($tab instanceof Tab) {
+ $this->tabs[$name] = $tab;
+ } else {
+ $this->tabs[$name] = new Tab($tab + array('name' => $name));
+ }
+ return $this;
+ }
+
+ /**
+ * Remove a tab
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function remove($name)
+ {
+ if ($this->has($name)) {
+ unset($this->tabs[$name]);
+ if (($dropdownIndex = array_search($name, $this->dropdownTabs, true)) !== false) {
+ array_splice($this->dropdownTabs, $dropdownIndex, 1);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a tab to the dropdown on the right side of the tab-bar.
+ *
+ * @param $name
+ * @param $tab
+ */
+ public function addAsDropdown($name, $tab)
+ {
+ $this->set($name, $tab);
+ $this->dropdownTabs[] = $name;
+ $this->dropdownTabs = array_unique($this->dropdownTabs);
+ }
+
+ /**
+ * Render the dropdown area with its tabs and return the resulting HTML
+ *
+ * @return mixed|string
+ */
+ private function renderDropdownTabs()
+ {
+ if (empty($this->dropdownTabs)) {
+ return '';
+ }
+ $tabs = '';
+ foreach ($this->dropdownTabs as $tabname) {
+ $tab = $this->get($tabname);
+ if ($tab === null) {
+ continue;
+ }
+ $tabs .= $tab;
+ }
+ return str_replace(array('{TABS}', '{TITLE}'), array($tabs, t('Dropdown menu')), $this->dropdownTpl);
+ }
+
+ /**
+ * Render all tabs, except the ones in dropdown area and return the resulting HTML
+ *
+ * @return string
+ */
+ private function renderTabs()
+ {
+ $tabs = '';
+ foreach ($this->tabs as $name => $tab) {
+ // ignore tabs added to dropdown
+ if (in_array($name, $this->dropdownTabs)) {
+ continue;
+ }
+ $tabs .= $tab;
+ }
+ return $tabs;
+ }
+
+ private function renderCloseTab()
+ {
+ return str_replace('{TITLE}', t('Close container'), $this->closeTpl);
+ }
+
+ private function renderRefreshTab()
+ {
+ $url = Url::fromRequest();
+ $tab = $this->get($this->getActiveName());
+
+ if ($tab !== null) {
+ $label = $this->view()->escape(
+ $tab->getLabel()
+ );
+ }
+
+ if (! empty($label)) {
+ $caption = $label;
+ } else {
+ $caption = t('Content');
+ }
+
+ $label = sprintf(t('Refresh the %s'), $caption);
+ $title = $label;
+
+ $tpl = str_replace(
+ array(
+ '{URL}',
+ '{TITLE}',
+ '{LABEL}'
+ ),
+ array(
+ $this->view()->escape($url->getAbsoluteUrl()),
+ $title,
+ $label
+ ),
+ $this->refreshTpl
+ );
+
+ return $tpl;
+ }
+
+ /**
+ * Render to HTML
+ *
+ * @see Widget::render
+ */
+ public function render()
+ {
+ if (empty($this->tabs) || true === $this->closeButtonOnly) {
+ $tabs = '';
+ $drop = '';
+ } else {
+ $tabs = $this->renderTabs();
+ $drop = $this->renderDropdownTabs();
+ }
+ $close = $this->closeTab ? $this->renderCloseTab() : '';
+ $refresh = $this->renderRefreshTab();
+
+ return str_replace(
+ array(
+ '{TABS}',
+ '{DROPDOWN}',
+ '{REFRESH}',
+ '{CLOSE}'
+ ),
+ array(
+ $tabs,
+ $drop,
+ $refresh,
+ $close
+ ),
+ $this->baseTpl
+ );
+ }
+
+ public function __toString()
+ {
+ try {
+ $html = $this->render();
+ } catch (Exception $e) {
+ return htmlspecialchars($e->getMessage());
+ }
+ return $html;
+ }
+
+ /**
+ * Return the number of tabs
+ *
+ * @return int
+ *
+ * @see Countable
+ */
+ public function count(): int
+ {
+ return count($this->tabs);
+ }
+
+ /**
+ * Return all tabs contained in this tab panel
+ *
+ * @return array
+ */
+ public function getTabs()
+ {
+ return $this->tabs;
+ }
+
+ /**
+ * Whether to hide all elements except of the close button
+ *
+ * @param bool $value
+ * @return Tabs fluent interface
+ */
+ public function showOnlyCloseButton($value = true)
+ {
+ $this->closeButtonOnly = $value;
+ return $this;
+ }
+
+ /**
+ * Apply a Tabextension on this tabs object
+ *
+ * @param Tabextension $tabextension
+ *
+ * @return $this
+ */
+ public function extend(Tabextension $tabextension)
+ {
+ $tabextension->apply($this);
+ return $this;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Widget.php b/library/Icinga/Web/Widget/Widget.php
new file mode 100644
index 0000000..879858a
--- /dev/null
+++ b/library/Icinga/Web/Widget/Widget.php
@@ -0,0 +1,24 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Web\View;
+use Zend_View_Abstract;
+
+/**
+ * Abstract class for reusable view elements that can be
+ * rendered to a view
+ *
+ */
+interface Widget
+{
+ /**
+ * Renders this widget via the given view and returns the
+ * HTML as a string
+ *
+ * @param \Zend_View_Abstract $view
+ * @return string
+ */
+ // public function render(Zend_View_Abstract $view);
+}
diff --git a/library/Icinga/Web/Window.php b/library/Icinga/Web/Window.php
new file mode 100644
index 0000000..158483a
--- /dev/null
+++ b/library/Icinga/Web/Window.php
@@ -0,0 +1,125 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\Session\SessionNamespace;
+
+class Window
+{
+ const UNDEFINED = 'undefined';
+
+ /** @var Window */
+ protected static $window;
+
+ /** @var string */
+ protected $id;
+
+ /** @var string */
+ protected $containerId;
+
+ public function __construct($id)
+ {
+ $parts = explode('_', $id, 2);
+ if (isset($parts[1])) {
+ $this->id = $parts[0];
+ $this->containerId = $id;
+ } else {
+ $this->id = $id;
+ }
+ }
+
+ /**
+ * Get whether the window's ID is undefined
+ *
+ * @return bool
+ */
+ public function isUndefined()
+ {
+ return $this->id === self::UNDEFINED;
+ }
+
+ /**
+ * Get the window's ID
+ *
+ * @return string
+ */
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ /**
+ * Get the container's ID
+ *
+ * @return string
+ */
+ public function getContainerId()
+ {
+ return $this->containerId ?: $this->id;
+ }
+
+ /**
+ * Return a window-aware session by using the given prefix
+ *
+ * @param string $prefix The prefix to use
+ * @param bool $reset Whether to reset any existing session-data
+ *
+ * @return SessionNamespace
+ */
+ public function getSessionNamespace($prefix, $reset = false)
+ {
+ $session = Session::getSession();
+
+ $identifier = $prefix . '_' . $this->getId();
+ if ($reset && $session->hasNamespace($identifier)) {
+ $session->removeNamespace($identifier);
+ }
+
+ $namespace = $session->getNamespace($identifier);
+ $nsUndef = $prefix . '_' . self::UNDEFINED;
+
+ if (! $reset && ! $this->isUndefined() && $session->hasNamespace($nsUndef)) {
+ // We may not have any window-id on the very first request. Now we add
+ // all values from the namespace, that has been created in this case,
+ // to the new one and remove it afterwards.
+ foreach ($session->getNamespace($nsUndef) as $name => $value) {
+ $namespace->set($name, $value);
+ }
+
+ $session->removeNamespace($nsUndef);
+ }
+
+ return $namespace;
+ }
+
+ /**
+ * Generate a random string
+ *
+ * @return string
+ */
+ public static function generateId()
+ {
+ $letters = 'abcefghijklmnopqrstuvwxyz';
+ return substr(str_shuffle($letters), 0, 12);
+ }
+
+ /**
+ * @return Window
+ */
+ public static function getInstance()
+ {
+ if (! isset(static::$window)) {
+ $id = Icinga::app()->getRequest()->getHeader('X-Icinga-WindowId');
+ if (empty($id) || $id === static::UNDEFINED) {
+ Icinga::app()->getResponse()->setOverrideWindowId();
+ $id = static::generateId();
+ }
+
+ static::$window = new Window($id);
+ }
+
+ return static::$window;
+ }
+}
diff --git a/library/Icinga/Web/Wizard.php b/library/Icinga/Web/Wizard.php
new file mode 100644
index 0000000..9a1b8b6
--- /dev/null
+++ b/library/Icinga/Web/Wizard.php
@@ -0,0 +1,720 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web;
+
+use Icinga\Forms\ConfigForm;
+use Icinga\Module\Setup\Forms\ModulePage;
+use LogicException;
+use InvalidArgumentException;
+use Icinga\Web\Session\SessionNamespace;
+use Icinga\Web\Form\Decorator\ElementDoubler;
+
+/**
+ * Container and controller for form based wizards
+ */
+class Wizard
+{
+ /**
+ * An integer describing the wizard's forward direction
+ */
+ const FORWARD = 0;
+
+ /**
+ * An integer describing the wizard's backward direction
+ */
+ const BACKWARD = 1;
+
+ /**
+ * An integer describing that the wizard does not change its position
+ */
+ const NO_CHANGE = 2;
+
+ /**
+ * The name of the button to advance the wizard's position
+ */
+ const BTN_NEXT = 'btn_next';
+
+ /**
+ * The name of the button to rewind the wizard's position
+ */
+ const BTN_PREV = 'btn_prev';
+
+ /**
+ * The name and id of the element for showing the user an activity indicator when advancing the wizard
+ */
+ const PROGRESS_ELEMENT = 'wizard_progress';
+
+ /**
+ * This wizard's parent
+ *
+ * @var Wizard
+ */
+ protected $parent;
+
+ /**
+ * The name of the wizard's current page
+ *
+ * @var string
+ */
+ protected $currentPage;
+
+ /**
+ * The pages being part of this wizard
+ *
+ * @var array
+ */
+ protected $pages = array();
+
+ /**
+ * Initialize a new wizard
+ */
+ public function __construct()
+ {
+ $this->init();
+ }
+
+ /**
+ * Run additional initialization routines
+ *
+ * Should be implemented by subclasses to add pages to the wizard.
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Return this wizard's parent or null in case it has none
+ *
+ * @return Wizard|null
+ */
+ public function getParent()
+ {
+ return $this->parent;
+ }
+
+ /**
+ * Set this wizard's parent
+ *
+ * @param Wizard $wizard The parent wizard
+ *
+ * @return $this
+ */
+ public function setParent(Wizard $wizard)
+ {
+ $this->parent = $wizard;
+ return $this;
+ }
+
+ /**
+ * Return the pages being part of this wizard
+ *
+ * In case this is a nested wizard a flattened array of all contained pages is returned.
+ *
+ * @return array
+ */
+ public function getPages()
+ {
+ $pages = array();
+ foreach ($this->pages as $page) {
+ if ($page instanceof self) {
+ $pages = array_merge($pages, $page->getPages());
+ } else {
+ $pages[] = $page;
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Return the page with the given name
+ *
+ * Note that it's also possible to retrieve a nested wizard's page by using this method.
+ *
+ * @param string $name The name of the page to return
+ *
+ * @return ModulePage|Form|null The page or null in case there is no page with the given name
+ */
+ public function getPage($name)
+ {
+ foreach ($this->getPages() as $page) {
+ if ($name === $page->getName()) {
+ return $page;
+ }
+ }
+ }
+
+ /**
+ * Add a new page or wizard to this wizard
+ *
+ * @param Form|Wizard $page The page or wizard to add to the wizard
+ *
+ * @return $this
+ */
+ public function addPage($page)
+ {
+ if (! $page instanceof Form && ! $page instanceof self) {
+ throw new InvalidArgumentException(
+ 'The $page argument must be an instance of Icinga\Web\Form '
+ . 'or Icinga\Web\Wizard but is of type: ' . get_class($page)
+ );
+ } elseif ($page instanceof self) {
+ $page->setParent($this);
+ }
+
+ $this->pages[] = $page;
+ return $this;
+ }
+
+ /**
+ * Add multiple pages or wizards to this wizard
+ *
+ * @param array $pages The pages or wizards to add to the wizard
+ *
+ * @return $this
+ */
+ public function addPages(array $pages)
+ {
+ foreach ($pages as $page) {
+ $this->addPage($page);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Assert that this wizard has any pages
+ *
+ * @throws LogicException In case this wizard has no pages
+ */
+ protected function assertHasPages()
+ {
+ $pages = $this->getPages();
+ if (count($pages) < 2) {
+ throw new LogicException("Although Chuck Norris can advance a wizard with less than two pages, you can't.");
+ }
+ }
+
+ /**
+ * Return the current page of this wizard
+ *
+ * @return Form
+ *
+ * @throws LogicException In case the name of the current page currently being set is invalid
+ */
+ public function getCurrentPage()
+ {
+ if ($this->parent) {
+ return $this->parent->getCurrentPage();
+ }
+
+ if ($this->currentPage === null) {
+ $this->assertHasPages();
+ $pages = $this->getPages();
+ $this->currentPage = $this->getSession()->get('current_page', $pages[0]->getName());
+ }
+
+ if (($page = $this->getPage($this->currentPage)) === null) {
+ throw new LogicException(sprintf('No page found with name "%s"', $this->currentPage));
+ }
+
+ return $page;
+ }
+
+ /**
+ * Set the current page of this wizard
+ *
+ * @param Form $page The page to set as current page
+ *
+ * @return $this
+ */
+ public function setCurrentPage(Form $page)
+ {
+ $this->currentPage = $page->getName();
+ $this->getSession()->set('current_page', $this->currentPage);
+ return $this;
+ }
+
+ /**
+ * Setup the given page that is either going to be displayed or validated
+ *
+ * Implement this method in a subclass to populate default values and/or other data required to process the form.
+ *
+ * @param Form $page The page to setup
+ * @param Request $request The current request
+ */
+ public function setupPage(Form $page, Request $request)
+ {
+ }
+
+ /**
+ * Process the given request using this wizard
+ *
+ * Validate the request data using the current page, update the wizard's
+ * position and redirect to the page's redirect url upon success.
+ *
+ * @param Request $request The request to be processed
+ *
+ * @return Request The request supposed to be processed
+ */
+ public function handleRequest(Request $request = null)
+ {
+ $page = $this->getCurrentPage();
+
+ if (($wizard = $this->findWizard($page)) !== null) {
+ return $wizard->handleRequest($request);
+ }
+
+ if ($request === null) {
+ $request = $page->getRequest();
+ }
+
+ $this->setupPage($page, $request);
+ $requestData = $this->getRequestData($page, $request);
+ if ($page->wasSent($requestData)) {
+ if (($requestedPage = $this->getRequestedPage($requestData)) !== null) {
+ $isValid = false;
+ $direction = $this->getDirection($request);
+ if ($direction === static::FORWARD && $page->isValid($requestData)) {
+ $isValid = true;
+ if ($this->isLastPage($page)) {
+ $this->setIsFinished();
+ }
+ } elseif ($direction === static::BACKWARD) {
+ $page->populate($requestData);
+ $isValid = true;
+ }
+
+ if ($isValid) {
+ $pageData = & $this->getPageData();
+ $pageData[$page->getName()] = ConfigForm::transformEmptyValuesToNull($page->getValues());
+ $this->setCurrentPage($this->getNewPage($requestedPage, $page));
+ $page->getResponse()->redirectAndExit($page->getRedirectUrl());
+ }
+ } elseif ($page->getValidatePartial()) {
+ $page->isValidPartial($requestData);
+ } else {
+ $page->populate($requestData);
+ }
+ } elseif (($pageData = $this->getPageData($page->getName())) !== null) {
+ $page->populate($pageData);
+ }
+
+ return $request;
+ }
+
+ /**
+ * Return the wizard for the given page or null if its not part of a wizard
+ *
+ * @param Form $page The page to return its wizard for
+ *
+ * @return Wizard|null
+ */
+ protected function findWizard(Form $page)
+ {
+ foreach ($this->getWizards() as $wizard) {
+ if ($wizard->getPage($page->getName()) === $page) {
+ return $wizard;
+ }
+ }
+ }
+
+ /**
+ * Return this wizard's child wizards
+ *
+ * @return array
+ */
+ protected function getWizards()
+ {
+ $wizards = array();
+ foreach ($this->pages as $pageOrWizard) {
+ if ($pageOrWizard instanceof self) {
+ $wizards[] = $pageOrWizard;
+ }
+ }
+
+ return $wizards;
+ }
+
+ /**
+ * Return the request data based on given form's request method
+ *
+ * @param Form $page The page to fetch the data for
+ * @param Request $request The request to fetch the data from
+ *
+ * @return array
+ */
+ protected function getRequestData(Form $page, Request $request)
+ {
+ if (strtolower($request->getMethod()) === $page->getMethod()) {
+ return $request->{'get' . ($request->isPost() ? 'Post' : 'Query')}();
+ }
+
+ return array();
+ }
+
+ /**
+ * Return the name of the requested page
+ *
+ * @param array $requestData The request's data
+ *
+ * @return null|string The name of the requested page or null in case no page has been requested
+ */
+ protected function getRequestedPage(array $requestData)
+ {
+ if ($this->parent) {
+ return $this->parent->getRequestedPage($requestData);
+ }
+
+ if (isset($requestData[static::BTN_NEXT])) {
+ return $requestData[static::BTN_NEXT];
+ } elseif (isset($requestData[static::BTN_PREV])) {
+ return $requestData[static::BTN_PREV];
+ }
+ }
+
+ /**
+ * Return the direction of this wizard using the given request
+ *
+ * @param Request $request The request to use
+ *
+ * @return int The direction @see Wizard::FORWARD @see Wizard::BACKWARD @see Wizard::NO_CHANGE
+ */
+ protected function getDirection(Request $request = null)
+ {
+ if ($this->parent) {
+ return $this->parent->getDirection($request);
+ }
+
+ $currentPage = $this->getCurrentPage();
+
+ if ($request === null) {
+ $request = $currentPage->getRequest();
+ }
+
+ $requestData = $this->getRequestData($currentPage, $request);
+ if (isset($requestData[static::BTN_NEXT])) {
+ return static::FORWARD;
+ } elseif (isset($requestData[static::BTN_PREV])) {
+ return static::BACKWARD;
+ }
+
+ return static::NO_CHANGE;
+ }
+
+ /**
+ * Return the new page to set as current page
+ *
+ * Permission is checked by verifying that the requested page or its previous page has page data available.
+ * The requested page is automatically permitted without any checks if the origin page is its previous
+ * page or one that occurs later in order.
+ *
+ * @param string $requestedPage The name of the requested page
+ * @param Form $originPage The origin page
+ *
+ * @return Form The new page
+ *
+ * @throws InvalidArgumentException In case the requested page does not exist or is not permitted yet
+ */
+ protected function getNewPage($requestedPage, Form $originPage)
+ {
+ if ($this->parent) {
+ return $this->parent->getNewPage($requestedPage, $originPage);
+ }
+
+ if (($page = $this->getPage($requestedPage)) !== null) {
+ $permitted = true;
+
+ $pages = $this->getPages();
+ if (! $this->hasPageData($requestedPage) && ($index = array_search($page, $pages, true)) > 0) {
+ $previousPage = $pages[$index - 1];
+ if ($originPage === null || ($previousPage->getName() !== $originPage->getName()
+ && array_search($originPage, $pages, true) < $index)) {
+ $permitted = $this->hasPageData($previousPage->getName());
+ }
+ }
+
+ if ($permitted) {
+ return $page;
+ }
+ }
+
+ throw new InvalidArgumentException(
+ sprintf('"%s" is either an unknown page or one you are not permitted to view', $requestedPage)
+ );
+ }
+
+ /**
+ * Return the next or previous page based on the given one
+ *
+ * @param Form $page The page to skip
+ *
+ * @return Form
+ */
+ protected function skipPage(Form $page)
+ {
+ if ($this->parent) {
+ return $this->parent->skipPage($page);
+ }
+
+ if ($this->hasPageData($page->getName())) {
+ $pageData = & $this->getPageData();
+ unset($pageData[$page->getName()]);
+ }
+
+ $pages = $this->getPages();
+ if ($this->getDirection() === static::FORWARD) {
+ $nextPage = $pages[array_search($page, $pages, true) + 1];
+ $newPage = $this->getNewPage($nextPage->getName(), $page);
+ } else { // $this->getDirection() === static::BACKWARD
+ $previousPage = $pages[array_search($page, $pages, true) - 1];
+ $newPage = $this->getNewPage($previousPage->getName(), $page);
+ }
+
+ return $newPage;
+ }
+
+ /**
+ * Return whether the given page is this wizard's last page
+ *
+ * @param Form $page The page to check
+ *
+ * @return bool
+ */
+ protected function isLastPage(Form $page)
+ {
+ if ($this->parent) {
+ return $this->parent->isLastPage($page);
+ }
+
+ $pages = $this->getPages();
+ return $page->getName() === end($pages)->getName();
+ }
+
+ /**
+ * Return whether all of this wizard's pages were visited by the user
+ *
+ * The base implementation just verifies that the very last page has page data available.
+ *
+ * @return bool
+ */
+ public function isComplete()
+ {
+ $pages = $this->getPages();
+ return $this->hasPageData($pages[count($pages) - 1]->getName());
+ }
+
+ /**
+ * Set whether this wizard has been completed
+ *
+ * @param bool $state Whether this wizard has been completed
+ *
+ * @return $this
+ */
+ public function setIsFinished($state = true)
+ {
+ $this->getSession()->set('isFinished', $state);
+ return $this;
+ }
+
+ /**
+ * Return whether this wizard has been completed
+ *
+ * @return bool
+ */
+ public function isFinished()
+ {
+ return $this->getSession()->get('isFinished', false);
+ }
+
+ /**
+ * Return the overall page data or one for a particular page
+ *
+ * Note that this method returns by reference so in order to update the
+ * returned array set this method's return value also by reference.
+ *
+ * @param string $pageName The page for which to return the data
+ *
+ * @return array
+ */
+ public function & getPageData($pageName = null)
+ {
+ $session = $this->getSession();
+
+ if (false === isset($session->page_data)) {
+ $session->page_data = array();
+ }
+
+ $pageData = & $session->getByRef('page_data');
+ if ($pageName !== null) {
+ $data = null;
+ if (isset($pageData[$pageName])) {
+ $data = & $pageData[$pageName];
+ }
+
+ return $data;
+ }
+
+ return $pageData;
+ }
+
+ /**
+ * Return whether there is any data for the given page
+ *
+ * @param string $pageName The name of the page to check
+ *
+ * @return bool
+ */
+ public function hasPageData($pageName)
+ {
+ return $this->getPageData($pageName) !== null;
+ }
+
+ /**
+ * Return a session to be used by this wizard
+ *
+ * @return SessionNamespace
+ */
+ public function getSession()
+ {
+ if ($this->parent) {
+ return $this->parent->getSession();
+ }
+
+ return Session::getSession()->getNamespace(get_class($this));
+ }
+
+ /**
+ * Clear the session being used by this wizard
+ */
+ public function clearSession()
+ {
+ $this->getSession()->clear();
+ }
+
+ /**
+ * Add buttons to the given page based on its position in the page-chain
+ *
+ * @param Form $page The page to add the buttons to
+ */
+ protected function addButtons(Form $page)
+ {
+ $pages = $this->getPages();
+ $index = array_search($page, $pages, true);
+ if ($index === 0) {
+ $page->addElement(
+ 'button',
+ static::BTN_NEXT,
+ array(
+ 'class' => 'control-button btn-primary',
+ 'type' => 'submit',
+ 'value' => $pages[1]->getName(),
+ 'label' => t('Next'),
+ 'decorators' => array('ViewHelper', 'Spinner')
+ )
+ );
+ } elseif ($index < count($pages) - 1) {
+ $page->addElement(
+ 'button',
+ static::BTN_PREV,
+ array(
+ 'class' => 'control-button',
+ 'type' => 'submit',
+ 'value' => $pages[$index - 1]->getName(),
+ 'label' => t('Back'),
+ 'decorators' => array('ViewHelper'),
+ 'formnovalidate' => 'formnovalidate'
+ )
+ );
+ $page->addElement(
+ 'button',
+ static::BTN_NEXT,
+ array(
+ 'class' => 'control-button btn-primary',
+ 'type' => 'submit',
+ 'value' => $pages[$index + 1]->getName(),
+ 'label' => t('Next'),
+ 'decorators' => array('ViewHelper')
+ )
+ );
+ } else {
+ $page->addElement(
+ 'button',
+ static::BTN_PREV,
+ array(
+ 'class' => 'control-button',
+ 'type' => 'submit',
+ 'value' => $pages[$index - 1]->getName(),
+ 'label' => t('Back'),
+ 'decorators' => array('ViewHelper'),
+ 'formnovalidate' => 'formnovalidate'
+ )
+ );
+ $page->addElement(
+ 'button',
+ static::BTN_NEXT,
+ array(
+ 'class' => 'control-button btn-primary',
+ 'type' => 'submit',
+ 'value' => $page->getName(),
+ 'label' => t('Finish'),
+ 'decorators' => array('ViewHelper')
+ )
+ );
+ }
+
+ $page->setAttrib('data-progress-element', static::PROGRESS_ELEMENT);
+ $page->addElement(
+ 'note',
+ static::PROGRESS_ELEMENT,
+ array(
+ 'order' => 99, // Ensures that it's shown on the right even if a sub-class adds another button
+ 'decorators' => array(
+ 'ViewHelper',
+ array('Spinner', array('id' => static::PROGRESS_ELEMENT))
+ )
+ )
+ );
+
+ $page->addDisplayGroup(
+ array(static::BTN_PREV, static::BTN_NEXT, static::PROGRESS_ELEMENT),
+ 'buttons',
+ array(
+ 'decorators' => array(
+ 'FormElements',
+ new ElementDoubler(array(
+ 'double' => static::BTN_NEXT,
+ 'condition' => static::BTN_PREV,
+ 'placement' => ElementDoubler::PREPEND,
+ 'attributes' => array('tabindex' => -1, 'class' => 'double')
+ )),
+ array('HtmlTag', array('tag' => 'div', 'class' => 'buttons'))
+ )
+ )
+ );
+ }
+
+ /**
+ * Return the current page of this wizard with appropriate buttons being added
+ *
+ * @return Form
+ */
+ public function getForm()
+ {
+ $form = $this->getCurrentPage();
+ $form->create(); // Make sure that buttons are displayed at the very bottom
+ $this->addButtons($form);
+ return $form;
+ }
+
+ /**
+ * Return the current page of this wizard rendered as HTML
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return (string) $this->getForm();
+ }
+}