From 0cbfebe21c42085fb16ba223e4a881efc4b45c4c Mon Sep 17 00:00:00 2001 From: Stephan Date: Tue, 20 Mar 2018 10:43:18 +0100 Subject: [PATCH 01/15] Port v7@2aa0dfb2c5 - WIP --- .editorconfig | 17 +- .gitattributes | 49 + .gitignore | 3 +- BUILD.md | 37 + CODE_OF_CONDUCT.md | 80 ++ CONTRIBUTING.md | 207 ++++ PULL_REQUEST_TEMPLATE.md | 11 + README.md | 35 +- build/Azure/azuregalleryrelease.ps1 | 59 ++ build/NuSpecs/UmbracoCms.Core.nuspec | 8 +- build/NuSpecs/UmbracoCms.nuspec | 1 + build/NuSpecs/tools/ReadmeUpgrade.txt | 2 +- build/NuSpecs/tools/install.ps1 | 41 + build/NuSpecs/tools/trees.config.install.xdt | 8 +- build/build.ps1 | 32 +- src/NuGet.Config | 2 +- src/Umbraco.Web.UI.Client/bower.json | 29 +- .../lib/bootstrap/less/thumbnails.less | 12 +- .../lib/umbraco/LegacyUmbClientMgr.js | 28 +- src/Umbraco.Web.UI.Client/src/app.js | 13 +- .../canvasdesigner.controller.js | 14 +- .../application/umbbackdrop.directive.js | 116 ++ .../umbdrawer/umbdrawer.directive.js | 109 ++ .../umbdrawer/umbdrawercontent.directive.js | 59 ++ .../umbdrawer/umbdrawerfooter.directive.js | 58 + .../umbdrawer/umbdrawerheader.directive.js | 63 ++ .../umbdrawer/umbdrawerview.directive.js | 58 + .../application/umbsections.directive.js | 43 +- .../application/umbtour.directive.js | 552 ++++++++++ .../umbtour/umbtourstep.directive.js | 35 + .../umbtour/umbtourstepcontent.directive.js | 22 + .../umbtour/umbtourstepcounter.directive.js | 22 + .../umbtour/umbtourstepfooter.directive.js | 19 + .../umbtour/umbtourstepheader.directive.js | 22 + .../components/buttons/umbbutton.directive.js | 3 +- .../components/content/edit.controller.js | 201 +++- .../content/umbcontentnodeinfo.directive.js | 293 ++++++ .../components/events/events.directive.js | 2 +- .../components/grid/grid.rte.directive.js | 7 +- .../media/umbmedianodeinfo.directive.js | 67 ++ .../overlays/umboverlay.directive.js | 3 +- .../property/umbproperty.directive.js | 19 +- .../components/tabs/umbtabs.directive.js | 11 +- .../components/tree/umbtree.directive.js | 16 +- .../components/tree/umbtreeitem.directive.js | 16 +- .../components/umbavatar.directive.js | 10 +- .../components/umbdatetimepicker.directive.js | 9 +- .../components/umbgroupsbuilder.directive.js | 3 +- .../components/umbmediagrid.directive.js | 2 +- .../components/umbnodepreview.directive.js | 2 +- .../components/umbpasswordtoggle.directive.js | 37 + .../components/umbprogresscircle.directive.js | 88 ++ .../components/umbtable.directive.js | 111 ++ .../validation/nodirtycheck.directive.js | 11 +- .../validation/valemail.directive.js | 4 +- .../common/resources/currentuser.resource.js | 24 + .../src/common/resources/entity.resource.js | 2 +- .../src/common/resources/log.resource.js | 68 +- .../src/common/resources/media.resource.js | 4 +- .../src/common/resources/member.resource.js | 99 +- .../src/common/resources/tour.resource.js | 35 + .../src/common/services/appstate.service.js | 38 + .../src/common/services/assets.service.js | 133 +-- .../src/common/services/backdrop.service.js | 106 ++ .../services/contenteditinghelper.service.js | 92 +- .../src/common/services/events.service.js | 40 +- .../src/common/services/help.service.js | 24 +- .../src/common/services/history.service.js | 23 +- .../common/services/listviewhelper.service.js | 11 +- .../common/services/menuactions.service.js | 20 +- .../src/common/services/navigation.service.js | 31 - .../src/common/services/tour.service.js | 281 +++++ .../services/umbdataformatter.service.js | 41 +- .../services/umbrequesthelper.service.js | 126 ++- .../common/services/usershelper.service.js | 12 + .../src/common/services/util.service.js | 992 +++++++++--------- .../src/controllers/main.controller.js | 36 +- .../src/controllers/search.controller.js | 35 +- src/Umbraco.Web.UI.Client/src/init.js | 35 +- .../src/installer/installer.service.js | 6 +- .../src/installer/steps/user.controller.js | 2 +- .../src/installer/steps/user.html | 2 +- .../src/less/application/grid.less | 11 +- src/Umbraco.Web.UI.Client/src/less/belle.less | 9 + .../src/less/buttons.less | 3 + .../components/application/umb-backdrop.less | 30 + .../components/application/umb-drawer.less | 194 ++++ .../less/components/application/umb-tour.less | 102 ++ .../components/buttons/umb-button-group.less | 1 + .../less/components/buttons/umb-button.less | 8 +- .../src/less/components/overlays.less | 7 +- .../overlays/umb-overlay-backdrop.less | 6 +- .../src/less/components/umb-avatar.less | 1 + .../src/less/components/umb-badge.less | 7 +- .../src/less/components/umb-breadcrumbs.less | 7 + .../components/umb-editor-navigation.less | 3 +- .../src/less/components/umb-empty-state.less | 2 +- .../src/less/components/umb-grid.less | 19 +- .../less/components/umb-group-builder.less | 203 ++-- .../src/less/components/umb-lightbox.less | 10 +- .../src/less/components/umb-node-preview.less | 11 +- .../src/less/components/umb-number-badge.less | 39 + .../less/components/umb-progress-circle.less | 49 + .../less/components/users/umb-user-cards.less | 2 + .../src/less/dashboards.less | 28 +- src/Umbraco.Web.UI.Client/src/less/forms.less | 10 + src/Umbraco.Web.UI.Client/src/less/hacks.less | 4 +- .../src/less/healthcheck.less | 4 +- src/Umbraco.Web.UI.Client/src/less/main.less | 1 - .../src/less/modals.less | 2 +- src/Umbraco.Web.UI.Client/src/less/navs.less | 5 + .../src/less/pages/login.less | 48 +- .../src/less/properties.less | 114 ++ .../src/less/property-editors.less | 233 ++-- .../src/less/sections.less | 4 - src/Umbraco.Web.UI.Client/src/less/tree.less | 4 +- .../src/less/variables.less | 30 + src/Umbraco.Web.UI.Client/src/routes.js | 24 +- .../common/dialogs/iconpicker.controller.js | 7 +- .../src/views/common/dialogs/iconpicker.html | 75 +- .../src/views/common/dialogs/insertmacro.html | 6 +- .../views/common/dialogs/login.controller.js | 755 ++++++------- .../src/views/common/dialogs/login.html | 445 ++++---- .../src/views/common/dialogs/mediapicker.html | 61 +- .../common/dialogs/membergrouppicker.html | 32 +- .../common/drawers/help/help.controller.js | 178 ++++ .../src/views/common/drawers/help/help.html | 130 +++ .../compositions/compositions.html | 24 +- .../editorpicker/editorpicker.html | 216 ++-- .../editorsettings/editorsettings.html | 44 +- .../propertysettings.controller.js | 310 +++--- .../propertysettings/propertysettings.html | 36 +- .../views/common/overlays/embed/embed.html | 8 +- .../overlays/iconpicker/iconpicker.html | 39 +- .../overlays/insertfield/insertfield.html | 23 +- .../overlays/itempicker/itempicker.html | 32 +- .../linkpicker/linkpicker.controller.js | 2 + .../overlays/linkpicker/linkpicker.html | 2 +- .../overlays/macropicker/macropicker.html | 13 +- .../mediaPicker/mediapicker.controller.js | 71 +- .../overlays/mediaPicker/mediapicker.html | 234 ++--- .../common/overlays/user/user.controller.js | 9 +- .../src/views/common/overlays/user/user.html | 14 + .../usergrouppicker/usergrouppicker.html | 25 +- .../overlays/userpicker/userpicker.html | 2 +- .../nodename/nodename.controller.js | 24 + .../nodename/nodename.html | 29 + .../doctypename/doctypename.controller.js | 24 + .../doctypename/doctypename.html | 29 + .../propertyname/propertyname.controller.js | 24 + .../propertyname/propertyname.html | 29 + .../tabname/tabname.controller.js | 24 + .../tabname/tabname.html | 29 + .../foldername/foldername.controller.js | 24 + .../foldername/foldername.html | 29 + .../uploadimages/uploadimages.controller.js | 41 + .../uploadimages/uploadimages.html | 29 + .../templatetree/templatetree.controller.js | 21 + .../templatetree/templatetree.html | 22 + .../components/application/umb-backdrop.html | 19 + .../application/umb-contextmenu.html | 5 +- .../application/umb-navigation.html | 195 ++-- .../components/application/umb-sections.html | 26 +- .../components/application/umb-tour.html | 86 ++ .../umbdrawer/umb-drawer-content.html | 1 + .../umbdrawer/umb-drawer-footer.html | 1 + .../umbdrawer/umb-drawer-header.html | 4 + .../umbdrawer/umb-drawer-view.html | 1 + .../application/umbdrawer/umb-drawer.html | 3 + .../umbtour/umb-tour-step-content.html | 4 + .../umbtour/umb-tour-step-counter.html | 1 + .../umbtour/umb-tour-step-footer.html | 1 + .../umbtour/umb-tour-step-header.html | 4 + .../application/umbtour/umb-tour-step.html | 10 + .../components/buttons/umb-button-group.html | 8 +- .../views/components/buttons/umb-button.html | 2 +- .../src/views/components/content/edit.html | 86 +- .../content/umb-content-node-info.html | 207 ++++ .../editor/umb-editor-container.html | 2 +- .../components/editor/umb-editor-footer.html | 2 +- .../components/editor/umb-editor-header.html | 33 +- .../editor/umb-editor-navigation.html | 3 +- .../components/media/umb-media-node-info.html | 59 ++ .../components/overlays/umb-overlay.html | 12 +- .../views/components/tabs/umb-tabs-nav.html | 2 +- .../components/umb-date-time-picker.html | 16 +- .../views/components/umb-groups-builder.html | 45 +- .../src/views/components/umb-media-grid.html | 6 +- .../views/components/umb-node-preview.html | 2 +- .../views/components/umb-progress-circle.html | 7 + .../src/views/components/umb-table.html | 12 +- .../components/upload/umb-file-dropzone.html | 6 +- .../views/content/content.edit.controller.js | 2 + .../content/content.restore.controller.js | 11 + .../src/views/content/create.html | 2 +- .../src/views/content/edit.html | 2 +- .../src/views/content/restore.html | 6 +- .../dashboard/dashboard.tabs.controller.js | 32 +- .../developer/developerdashboardvideos.html | 16 +- .../developer/examinemanagement.html | 2 +- .../dashboard/developer/redirecturls.html | 2 +- .../dashboard/forms/formsdashboardintro.html | 2 +- .../src/views/datatypes/create.html | 4 +- .../src/views/datatypes/edit.html | 2 +- .../src/views/documenttypes/create.html | 6 +- .../views/documenttypes/edit.controller.js | 51 +- .../src/views/documenttypes/edit.html | 3 +- .../src/views/media/create.html | 6 +- .../src/views/media/edit.html | 23 +- .../views/media/media.create.controller.js | 7 +- .../src/views/media/media.edit.controller.js | 7 + .../src/views/mediatypes/create.html | 2 +- .../src/views/mediatypes/edit.html | 2 +- .../src/views/member/edit.html | 12 +- .../views/member/member.edit.controller.js | 6 + .../src/views/membertypes/edit.html | 2 +- .../src/views/packager/overview.html | 2 +- .../src/views/partialviewmacros/edit.html | 2 +- .../src/views/partialviews/edit.html | 2 +- .../src/views/prevalueeditors/boolean.html | 2 +- .../prevalueeditors/imagepicker.controller.js | 31 +- .../views/prevalueeditors/imagepicker.html | 25 +- .../prevalueeditors/textstringlimited.html | 12 + .../colorpicker/colorpicker.controller.js | 138 ++- .../colorpicker/colorpicker.html | 8 +- .../colorpicker/colorpicker.prevalues.html | 5 +- .../multicolorpicker.controller.js | 52 +- .../contentpicker/contentpicker.controller.js | 12 +- .../contentpicker/contentpicker.html | 38 +- .../datepicker/datepicker.controller.js | 3 +- .../dropdownFlexible.controller.js | 81 ++ .../dropdownFlexible/dropdownFlexible.html | 19 + .../views/propertyeditors/email/email.html | 2 +- .../fileupload/fileupload.controller.js | 3 + .../fileupload/fileupload.html | 2 +- .../grid/dialogs/rowconfig.html | 16 +- .../propertyeditors/grid/grid.controller.js | 29 +- .../src/views/propertyeditors/grid/grid.html | 2 +- .../propertyeditors/grid/grid.prevalues.html | 4 +- .../includeproperties.prevalues.controller.js | 16 +- .../listview/includeproperties.prevalues.html | 2 +- .../list/list.listviewlayout.controller.js | 24 +- .../markdowneditor.controller.js | 8 +- .../mediapicker/mediapicker.controller.js | 68 +- .../mediapicker/mediapicker.html | 77 +- .../relatedlinks/relatedlinks.controller.js | 4 + .../propertyeditors/rte/rte.controller.js | 13 +- .../rte/rte.prevalues.controller.js | 1 + .../sensitivevalue/sensitivevalue.html | 5 + .../slider/slider.controller.js | 3 +- .../textbox/textbox.controller.js | 27 +- .../propertyeditors/textbox/textbox.html | 12 +- .../src/views/scripts/edit.html | 3 +- .../src/views/templates/edit.html | 8 +- .../src/views/users/overview.controller.js | 2 +- .../src/views/users/overview.html | 2 +- .../src/views/users/user.controller.js | 124 ++- .../src/views/users/user.html | 467 +-------- .../src/views/users/views/groups/groups.html | 1 + .../src/views/users/views/user/details.html | 361 +++++++ .../users/views/users/users.controller.js | 46 +- .../app/media/edit-media-controller.spec.js | 37 +- .../unit/common/directives/val-email.spec.js | 3 +- src/WebPi/TBEX.xml | 76 -- src/WebPi/installSQL1.sql | 9 - src/WebPi/installSQL2.sql | 15 - src/WebPi/manifest.xml | 45 - src/WebPi/parameters.xml | 161 --- src/WebPi/umbraco/favicon.ico | Bin 15934 -> 0 bytes tools/contributing/clonefork.png | Bin 0 -> 12248 bytes tools/contributing/createpullrequest.png | Bin 0 -> 26943 bytes tools/contributing/defaultbranch.png | Bin 0 -> 18754 bytes tools/contributing/forkrepository.png | Bin 0 -> 12700 bytes tools/contributing/gulpbuild.png | Bin 0 -> 20486 bytes 274 files changed, 9049 insertions(+), 3647 deletions(-) create mode 100644 .gitattributes create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 build/Azure/azuregalleryrelease.ps1 create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbbackdrop.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawer.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawercontent.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerfooter.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerheader.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerview.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstep.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcontent.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcounter.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepfooter.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepheader.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbcontentnodeinfo.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/media/umbmedianodeinfo.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/umbpasswordtoggle.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/directives/components/umbprogresscircle.directive.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/services/backdrop.service.js create mode 100644 src/Umbraco.Web.UI.Client/src/common/services/tour.service.js create mode 100644 src/Umbraco.Web.UI.Client/src/less/components/application/umb-backdrop.less create mode 100644 src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less create mode 100644 src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less create mode 100644 src/Umbraco.Web.UI.Client/src/less/components/umb-number-badge.less create mode 100644 src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less create mode 100644 src/Umbraco.Web.UI.Client/src/less/properties.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umb-backdrop.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umb-tour.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-content.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-footer.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-header.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-view.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-content.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-counter.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-footer.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-header.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/content/umb-content-node-info.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umb-media-node-info.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/umb-progress-circle.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/prevalueeditors/textstringlimited.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/dropdownFlexible/dropdownFlexible.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/dropdownFlexible/dropdownFlexible.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/sensitivevalue/sensitivevalue.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html delete mode 100644 src/WebPi/TBEX.xml delete mode 100644 src/WebPi/installSQL1.sql delete mode 100644 src/WebPi/installSQL2.sql delete mode 100644 src/WebPi/manifest.xml delete mode 100644 src/WebPi/parameters.xml delete mode 100644 src/WebPi/umbraco/favicon.ico create mode 100644 tools/contributing/clonefork.png create mode 100644 tools/contributing/createpullrequest.png create mode 100644 tools/contributing/defaultbranch.png create mode 100644 tools/contributing/forkrepository.png create mode 100644 tools/contributing/gulpbuild.png diff --git a/.editorconfig b/.editorconfig index 2a15465c59..5f3b4d684a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,17 @@ -root=true +# editorconfig.org +# top-most EditorConfig file +root = true + +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation [*] -end_of_line = crlf +insert_final_newline = true +end_of_line = lf indent_style = space indent_size = 4 -trim_trailing_whitespace = true -[*.{cs,cshtml,csx,vb,vbx,vbhtml,fs,fsx,txt,ps1,sql}] -indent_size = 4 +# Trim trailing whitespace, limited support. +# https://github.com/editorconfig/editorconfig/wiki/Property-research:-Trim-trailing-spaces +trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..a664be3a85 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,49 @@ +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain + +*.jpg binary +*.png binary +*.gif binary + +*.cs text=auto diff=csharp +*.vb text=auto +*.c text=auto +*.cpp text=auto +*.cxx text=auto +*.h text=auto +*.hxx text=auto +*.py text=auto +*.rb text=auto +*.java text=auto +*.html text=auto +*.htm text=auto +*.css text=auto +*.scss text=auto +*.sass text=auto +*.less text=auto +*.js text=auto +*.lisp text=auto +*.clj text=auto +*.sql text=auto +*.php text=auto +*.lua text=auto +*.m text=auto +*.asm text=auto +*.erl text=auto +*.fs text=auto +*.fsx text=auto +*.hs text=auto + +*.csproj text=auto merge=union +*.vbproj text=auto merge=union +*.fsproj text=auto merge=union +*.dbproj text=auto merge=union +*.sln text=auto eol=crlf merge=union diff --git a/.gitignore b/.gitignore index c5d51abd0d..e03ef6f408 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,7 @@ src/Umbraco.Tests/media tools/docfx/* apidocs/_site/* src/*/project.lock.json +src/.idea/* apidocs/api/* build/docs.zip @@ -149,4 +150,4 @@ src/PrecompiledWeb/* build.out/ build.tmp/ build/hooks/ -build/temp/ \ No newline at end of file +build/temp/ diff --git a/BUILD.md b/BUILD.md index 9515e26654..30ea950778 100644 --- a/BUILD.md +++ b/BUILD.md @@ -14,6 +14,43 @@ By default, this builds the current version. It is possible to specify a differe Valid version strings are defined in the `Set-UmbracoVersion` documentation below. +## PowerShell Quirks + +There is a good chance that running `build.ps1` ends up in error, with messages such as + +>The file ...\build\build.ps1 is not digitally signed. You cannot run this script on the current system. For more information about running scripts and setting execution policy, see about_Execution_Policies. + +PowerShell has *Execution Policies* that may prevent the script from running. You can check the current policies with: + + PS> Get-ExecutionPolicy -List + + Scope ExecutionPolicy + ----- --------------- + MachinePolicy Undefined + UserPolicy Undefined + Process Undefined + CurrentUser Undefined + LocalMachine RemoteSigned + +Policies can be `Restricted`, `AllSigned`, `RemoteSigned`, `Unrestricted` and `Bypass`. Scopes can be `MachinePolicy`, `UserPolicy`, `Process`, `CurrentUser`, `LocalMachine`. You need the current policy to be `RemoteSigned`—as long as it is `Undefined`, the script cannot run. You can change the current user policy with: + + PS> Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned + +Alternatively, you can do it at machine level, from within an elevated PowerShell session: + + PS> Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy RemoteSigned + +And *then* the script should run. It *might* however still complain about executing scripts, with messages such as: + +>Security warning - Run only scripts that you trust. While scripts from the internet can be useful, this script can potentially harm your computer. If you trust this script, use the Unblock-File cmdlet to allow the script to run without this warning message. Do you want to run ...\build\build.ps1? +[D] Do not run [R] Run once [S] Suspend [?] Help (default is "D"): + +This is usually caused by the scripts being *blocked*. And that usually happens when the source code has been downloaded as a Zip file. When Windows downloads Zip files, they are marked as *blocked* (technically, they have a Zone.Identifier alternate data stream, with a value of "3" to indicate that they were downloaded from the Internet). And when such a Zip file is un-zipped, each and every single file is also marked as blocked. + +The best solution is to unblock the Zip file before un-zipping: right-click the files, open *Properties*, and there should be a *Unblock* checkbox at the bottom of the dialog. If, however, the Zip file has already been un-zipped, it is possible to recursively unblock all files from PowerShell with: + + PS> Get-ChildItem -Recurse *.* | Unblock-File + ## Notes Git might have issues dealing with long file paths during build. You may want/need to enable `core.longpaths` support (see [this page](https://github.com/msysgit/msysgit/wiki/Git-cannot-create-a-file-or-directory-with-a-long-path) for details). diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..3baa5dbe66 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,80 @@ +# Code of Conduct + +## 1. Purpose + +A primary goal of Umbraco CMS is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). + +This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. + +We invite all those who participate in Umbraco CMS to help us create safe and positive experiences for everyone. + +## 2. Open Source Citizenship + +A supplemental goal of this Code of Conduct is to increase open source citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. + +Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. + +If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. + +## 3. Expected Behavior + +The following behaviors are expected and requested of all community members: + +* Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. +* Exercise consideration and respect in your speech and actions. +* Attempt collaboration before conflict. +* Refrain from demeaning, discriminatory, or harassing behavior and speech. +* Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. +* Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. + +## 4. Unacceptable Behavior + +The following behaviors are considered harassment and are unacceptable within our community: + +* Violence, threats of violence or violent language directed against another person. +* Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. +* Posting or displaying sexually explicit or violent material. +* Posting or threatening to post other people’s personally identifying information ("doxing"). +* Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. +* Inappropriate photography or recording. +* Inappropriate physical contact. You should have someone’s consent before touching them. +* Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. +* Deliberate intimidation, stalking or following (online or in person). +* Advocating for, or encouraging, any of the above behavior. +* Sustained disruption of community events, including talks and presentations. + +## 5. Consequences of Unacceptable Behavior + +Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. + +Anyone asked to stop unacceptable behavior is expected to comply immediately. + +If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). + +## 6. Reporting Guidelines + +If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. Please contact Sebastiaan Janssen - [sj@umbraco.dk](mailto:sj@umbraco.dk). + +Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. + +## 7. Addressing Grievances + +If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify Umbraco with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. + +## 8. Scope + +We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues–online and in-person–as well as in all one-on-one communications pertaining to community business. + +This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. + +## 9. Contact info + +Sebastiaan Janssen - [sj@umbraco.dk](mailto:sj@umbraco.dk) + +## 10. License and attribution + +This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). + +Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). + +Retrieved on November 22, 2016 from [http://citizencodeofconduct.org/](http://citizencodeofconduct.org/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..0e87f9824d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,207 @@ +# Contributing to Umbraco CMS + +👍🎉 First off, thanks for taking the time to contribute! 🎉👍 + +The following is a set of guidelines for contributing to Umbraco CMS. + +These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. + +Remember, we're a friendly bunch and are happy with whatever contribution you might provide. Below are guidelines for success that we've gathered over the years. If you choose to ignore them then we still love you 💖. + +#### Table Of Contents + +[Code of Conduct](#code-of-conduct) + +[How Can I Contribute?](#how-can-i-contribute) + * [Reporting Bugs](#reporting-bugs) + * [Suggesting Enhancements](#suggesting-enhancements) + * [Your First Code Contribution](#your-first-code-contribution) + * [Pull Requests](#pull-requests) + +[Styleguides](#styleguides) + +[What should I know before I get started?](#what-should-i-know-before-i-get-started) + * [Working with the source code](#working-with-the-source-code) + * [What branch should I target for my contributions?](#what-branch-should-i-target-for-my-contributions) + * [Building Umbraco from source code](#building-umbraco-from-source-code) + * [Keeping your Umbraco fork in sync with the main repository](#keeping-your-umbraco-fork-in-sync-with-the-main-repository) + +[How do I even begin?](#how-do-i-even-begin) + +[Problems?](#problems) + +[Credits](#credits) + +## Code of Conduct + +This project and everyone participating in it is governed by the [our Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [Sebastiaan Janssen - sj@umbraco.dk](mailto:sj@umbraco.dk). + +## How Can I Contribute? + +### Reporting Bugs +This section guides you through submitting a bug report for Umbraco CMS. Following these guidelines helps maintainers and the community understand your report 📝, reproduce the behavior 💻 💻, and find related reports 🔎. + +Before creating bug reports, please check [this list](#before-submitting-a-bug-report) as you might find out that you don't need to create one. When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). Fill out [the required template](ISSUE_TEMPLATE.md), the information it asks for helps us resolve issues faster. + +> **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. + +##### Before Submitting A Bug Report + + * Most importantly, check **if you can reproduce the problem** in the [latest version of Umbraco](https://our.umbraco.org/download/). We might have already fixed your particular problem. + * It also helps tremendously to check if the issue you're experiencing is present in **a clean install** of the Umbraco version you're currently using. Custom code can have side-effects that don't occur in a clean install. + * **Use the Google**. Whatever you're experiencing, Google it plus "Umbraco" - usually you can get some pretty good hints from the search results, including open issues and further troubleshooting hints. + * If you do find and existing issue has **and the issue is still open**, add a comment to the existing issue if you have additional information. If you have the same problem and no new info to add, just "star" the issue. + +Explain the problem and include additional details to help maintainers reproduce the problem. The following is a long description which we've boiled down into a few very simple question in the issue tracker when you create a new issue. We're listing the following hints to indicate that the most successful reports usually have a lot of this ground covered: + + * **Use a clear and descriptive title** for the issue to identify the problem. + * **Describe the exact steps which reproduce the problem** in as many details as possible. For example, start by explaining which steps you took in the backoffice to get to a certain undesireable result, e.g. you created a document type, inherting 3 levels deep, added a certain datatype, tried to save it and you got an error. + * **Provide specific examples to demonstrate the steps**. If you wrote some code, try to provide a code sample as specific as possible to be able to reproduce the behavior. + * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. + * **Explain which behavior you expected to see instead and why.** + +Provide more context by answering these questions: + + * **Can you reproduce the problem** when `debug="false"` in your `web.config` file? + * **Did the problem start happening recently** (e.g. after updating to a new version of Umbraco) or was this always a problem? + * **Can you reliably reproduce the issue?** If not, provide details about how often the problem happens and under which conditions it normally happens. + +Include details about your configuration and environment: + + * **Which version of Umbraco are you using?** + * **What is the environment you're using Umbraco in?** Is this a problem on your local machine or on a server. Tell us about your configuration: Windows version, IIS/IISExpress, database type, etc. + * **Which packages do you have installed?** + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for Atom, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion 📝 and find related suggestions 🔎. + +Most of the suggestions in the [reporting bugs](#reporting-bugs) section also count for suggesting enhancements. + +Some additional hints that may be helpful: + + * **Include screenshots and animated GIFs** which help you demonstrate the steps or point out the part of Umbraco which the suggestion is related to. + * **Explain why this enhancement would be useful to most Umbraco users** and isn't something that can or should be implemented as a [community package](https://our.umbraco.org/projects/). + +### Your First Code Contribution + +Unsure where to begin contributing to Umbraco? You can start by looking through [these `Up for grabs` and issues](http://issues.umbraco.org/issues/U4?q=%28project%3A+%7BU4%7D+Difficulty%3A+%7BVery+Easy%7D+%23Easy+%23Unresolved+Priority%3A+Normal+%23Major+%23Show-stopper+State%3A+-%7BIn+Progress%7D+sort+by%3A+votes+Affected+versions%3A+-6.*+Affected+versions%3A+-4.*%29+OR+%28tag%3A+%7BUp+For+Grabs%7D+%23Unresolved+%29). + +The issue list is sorted by total number of upvotes. While not perfect, number of upvotes is a reasonable proxy for impact a given change will have. + +### Pull Requests + +The most successful pull requests usually look a like this: + + * Fill in the required template + * Include screenshots and animated GIFs in your pull request whenever possible. + * Unit tests, while optional are awesome, thank you! + * New code is commented with documentation from which [the reference documentation](https://our.umbraco.org/documentation/Reference/) is generated + +Again, these are guidelines, not strict requirements. + +## Styleguides + +To be honest, we don't like rules very much. We trust you have the best of intentions and we encourage you to create working code. If it doesn't look perfect then we'll happily help clean it up. + +That said, the Umbraco development team likes to follow the hints that ReSharper gives us (no problem if you don't have this installed) and we've added a `.editorconfig` file so that Visual Studio knows what to do with whitespace, line endings, etc. + +## What should I know before I get started? + +### Working with the source code + +Some parts of our source code is over 10 years old now. And when we say "old", we mean "mature" of course! + +There's two big areas that you should know about: + + 1. The Umbraco backoffice is a extensible AngularJS app and requires you to run a `gulp dev` command while you're working with it, so changes are copied over to the appropriate directories and you can refresh your browser to view the results of your changes. + You may need to run the following commands to set up gulp properly: + ``` + npm cache clean + npm install -g bower + npm install -g gulp + npm install -g gulp-cli + npm install + gulp build + ``` + 2. "The rest" is a C# based codebase, with some traces of our WebForms past but mostly ASP.NET MVC based these days. You can make changes, build them in Visual Studio, and hit `F5` to see the result. + +To find the general areas of something you're looking to fix or improve, have a look at the following two parts of the API documentation. + + * [The AngularJS based backoffice files](https://our.umbraco.org/apidocs/ui/#/api) (to be found in `src\Umbraco.Web.UI.Client\src`) + * [The rest](https://our.umbraco.org/apidocs/csharp/) + +### What branch should I target for my contributions? + +We like to use [Gitflow as much as possible](https://jeffkreeftmeijer.com/git-flow/), don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to something, usually `dev-v7`. Whatever the default is, that's where we'd like you to target your contributions. + +![What branch do you want me to target?](tools/contributing/defaultbranch.png) + +### Building Umbraco from source code + +The easiest way to get started is to run `build.bat` which will build both the backoffice (also known as "Belle") and the Umbraco core. You can then easily start debugging from Visual Studio, or if you need to debug Belle you can run `gulp dev` in `src\Umbraco.Web.UI.Client`. See [this page](BUILD.md) for more details. + +Alternatively, you can open `src\umbraco.sln` in Visual Studio 2017 ([the community edition is free](https://www.visualstudio.com/thank-you-downloading-visual-studio/?sku=Community&rel=15) for you to use to contribute to Open Source projects). In Visual Studio, find the Task Runner Explorer (in the View menu under Other Windows) and run the build task under the gulpfile. + +![Gulp build in Visual Studio](tools/contributing/gulpbuild.png) + +After this build completes, you should be able to hit `F5` in Visual Studio to build and run the project. A IISExpress webserver will start and the Umbraco installer will pop up in your browser, follow the directions there to get a working Umbraco install up and running. + +### Keeping your Umbraco fork in sync with the main repository + +We recommend you sync with our repository before you submit your pull request. That way, you can fix any potential merge conflicts and make our lives a little bit easier. + +Also, if you've submitted a pull request three weeks ago and want to work on something new, you'll want to get the latest code to build against of course. + +To sync your fork with this original one, you'll have to add the upstream url, you only have to do this once: + +``` +git remote add upstream https://github.com/umbraco/Umbraco-CMS.git +``` + +Then when you want to get the changes from the main repository: + +``` +git fetch upstream +git rebase upstream/dev-v7 +``` + +In this command we're syncing with the `dev-v7` branch, but you can of course choose another one if needed. + +(More info on how this works: [http://robots.thoughtbot.com/post/5133345960/keeping-a-git-fork-updated](http://robots.thoughtbot.com/post/5133345960/keeping-a-git-fork-updated)) + +## How do I even begin? + +Great question! The short version goes like this: + + * **Fork** - create a fork of [`Umbraco-CMS` on GitHub](https://github.com/umbraco/Umbraco-CMS) + + ![Fork the repository](tools/contributing/forkrepository.png) + + * **Clone** - when GitHub has created your fork, you can clone it in your favorite Git tool + + ![Clone the fork](tools/contributing/clonefork.png) + + * **Build** - build your fork of Umbraco locally as described in [building Umbraco from source code](#building-umbraco-from-source-code) + * **Change** - make your changes, experiment, have fun, explore and learn, and don't be afraid. We welcome all contributions and will happily give feedback + * **Commit** - done? Yay! 🎉 It is recommended to create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp-U4-12345`. This means it's a temporary branch for the particular issue you're working on, in this case `U4-12345` + * **Push** - great, now you can push the changes up to your fork on GitHub + * **Create pull request** - exciting! You're ready to show us your changes (or not quite ready, you just need some feedback to progress). GitHub has picked up on the new branch you've pushed and will offer to create a Pull Request. Click that green button and away you go. + + ![Create a pull request](tools/contributing/createpullrequest.png) + +The Umbraco development team can now start reviewing your proposed changes and give you feedback on them. If it's not perfect, we'll either fix up what we need or we can request you to make some additional changes. + +If you make the corrections we ask for in the same branch and push them to your fork again, the pull request automatically updates with the additional commit(s) so we can review it again. If all is well, we'll merge the code and your commits are forever part of Umbraco! + +Not all changes are wanted so on occassion we might close a PR without merging it. We will give you feedback why we can't accept your changes at this and we'll be nice about it, thanking you for spending your valueable time. + +Remember, if an issue is in the `Up for grabs` list or you've asked for some feedback before you send us a PR, your PR will not be closed as unwanted. + +## Problems? + +Did something not work as expected? Try leaving a note in the ["Contributing to Umbraco"](https://our.umbraco.org/forum/contributing-to-umbraco-cms/) forum, the team monitors that one closely! + +## Credits + +This contribution guide borrows heavily from the excellent work on [the Atom contribution guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md). A big [#h5yr](http://h5yr.com/) to them! diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..db1e5c88bd --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +### Prerequisites + +- [ ] I have written a descriptive pull-request title +- [ ] I have linked this PR to an issue on the tracker at http://issues.umbraco.org + +### Description + + + + + diff --git a/README.md b/README.md index 69aba2f06a..045c91fae8 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,44 @@ Umbraco CMS =========== -The friendliest, most flexible and fastest growing ASP.NET CMS used by more than 350,000 websites worldwide: [https://umbraco.com](https://umbraco.com) +The friendliest, most flexible and fastest growing ASP.NET CMS used by more than 443,000 websites worldwide: [https://umbraco.com](https://umbraco.com) [![ScreenShot](vimeo.png)](https://vimeo.com/172382998/) -## Umbraco CMS ## +## Umbraco CMS Umbraco is a free open source Content Management System built on the ASP.NET platform. Our mission is to help you deliver delightful digital experiences by making Umbraco friendly, simpler and social. - -## Building Umbraco from source ## - -The easiest way to get started is to run `build/build.bat` which will build both the backoffice (also known as "Belle") and the Umbraco core. You can then easily start debugging from Visual Studio, or if you need to debug Belle you can run `gulp dev` in `src\Umbraco.Web.UI.Client`. - -Note that you can always [download a nightly build](http://nightly.umbraco.org/?container=umbraco-750) so you don't have to build the code yourself. - -## Watch an introduction video ## +## Watch an introduction video [![ScreenShot](http://umbraco.com/images/whatisumbraco.png)](https://umbraco.tv/videos/umbraco-v7/content-editor/basics/introduction/cms-explanation/) -## Umbraco - The Friendly CMS ## +## Umbraco - The Friendly CMS For the first time on the Microsoft platform, there is a free user and developer friendly CMS that makes it quick and easy to create websites - or a breeze to build complex web applications. Umbraco has award-winning integration capabilities and supports ASP.NET MVC or Web Forms, including User and Custom Controls, out of the box. Umbraco is not only loved by developers, but is a content editors dream. Enjoy intuitive editing tools, media management, responsive views and approval workflows to send your content live. -Used by more than 350,000 active websites including Carlsberg, Segway, Amazon and Heinz and **The Official ASP.NET and IIS.NET website from Microsoft** ([https://asp.net](https://asp.net) / [https://iis.net](https://iis.net)), you can be sure that the technology is proven, stable and scales. Backed by the team at Umbraco HQ, and supported by a dedicated community of over 200,000 craftspeople globally, you can trust that Umbraco is a safe choice and is here to stay. +Used by more than 443,000 active websites including Carlsberg, Segway, Amazon and Heinz and **The Official ASP.NET and IIS.NET website from Microsoft** ([https://asp.net](https://asp.net) / [https://iis.net](https://iis.net)), you can be sure that the technology is proven, stable and scales. Backed by the team at Umbraco HQ, and supported by a dedicated community of over 220,000 craftspeople globally, you can trust that Umbraco is a safe choice and is here to stay. To view more examples, please visit [https://umbraco.com/why-umbraco/#caseStudies](https://umbraco.com/why-umbraco/#caseStudies) -## Why Open Source? ## +## Why Open Source? As an Open Source platform, Umbraco is more than just a CMS. We are transparent with our roadmap for future versions, our incremental sprint planning notes are publicly accessible and community contributions and packages are available for all to use. -## Downloading ## +## Trying out Umbraco CMS -The downloadable Umbraco releases live at [https://our.umbraco.org/download](https://our.umbraco.org/download). +[Umbraco Cloud](https://umbraco.com) is the easiest and fastest way to use Umbraco yet with full support for all your custom .NET code and intergrations. You're up and running in less than a minute and your life will be made easier with automated upgrades and a built-in deployment engine. We offer a free 14 day trial, no credit card needed. -## Forums ## +If you want to DIY you can [download Umbraco](https://our.umbraco.org/download) either as a ZIP file or via NuGet. It's the same version of Umbraco CMS that powers Umbraco Xloud, but you'll need to find a place to host yourself and handling deployments and upgrades is all down to you. -Peer-to-peer support is available 24/7 at the community forum on [https://our.umbraco.org](https://our.umbraco.org). +## Community -## Contribute to Umbraco ## +Our friendly community is available 24/7 at the community hub we call ["Our Umbraco"](https://our.umbraco.org). Our Umbraco feature forums for questions and answers, documentation, downloadable plugins for Umbraco and a rich collection of community resources. -Umbraco is contribution focused and community driven. If you want to contribute back to Umbraco please check out our [guide to contributing](https://our.umbraco.org/contribute). +## Contribute to Umbraco -## Found a bug? ## +Umbraco is contribution focused and community driven. If you want to contribute back to Umbraco please check out our [guide to contributing](CONTRIBUTING.md). + +## Found a bug? Another way you can contribute to Umbraco is by providing issue reports. For information on how to submit an issue report refer to our [online guide for reporting issues](https://our.umbraco.org/contribute/report-an-issue-or-request-a-feature). diff --git a/build/Azure/azuregalleryrelease.ps1 b/build/Azure/azuregalleryrelease.ps1 new file mode 100644 index 0000000000..502ca3010e --- /dev/null +++ b/build/Azure/azuregalleryrelease.ps1 @@ -0,0 +1,59 @@ +Param( + [string]$GitHubPersonalAccessToken, + [string]$Directory +) +$workingDirectory = $Directory +CD $workingDirectory + +# Clone repo +$fullGitUrl = "https://$env:GIT_URL/$env:GIT_REPOSITORYNAME.git" +git clone $fullGitUrl 2>&1 | % { $_.ToString() } + +# Remove everything so that unzipping the release later will update everything +# Don't remove the readme file nor the git directory +Write-Host "Cleaning up git directory before adding new version" +Remove-Item -Recurse $workingDirectory\$env:GIT_REPOSITORYNAME\* -Exclude README.md,.git + +# Find release zip +$zipsDir = "$workingDirectory\$env:BUILD_DEFINITIONNAME\zips" +$pattern = "UmbracoCms.([0-9]{1,2}.[0-9]{1,3}.[0-9]{1,3}).zip" +Write-Host "Searching for Umbraco release files in $workingDirectory\$zipsDir for a file with pattern $pattern" +$file = (Get-ChildItem $zipsDir | Where-Object { $_.Name -match "$pattern" }) + +if($file) +{ + # Get release name + $version = [regex]::Match($file.Name, $pattern).captures.groups[1].value + $releaseName = "Umbraco $version" + Write-Host "Found $releaseName" + + # Unzip into repository to update release + Add-Type -AssemblyName System.IO.Compression.FileSystem + Write-Host "Unzipping $($file.FullName) to $workingDirectory\$env:GIT_REPOSITORYNAME" + [System.IO.Compression.ZipFile]::ExtractToDirectory("$($file.FullName)", "$workingDirectory\$env:GIT_REPOSITORYNAME") + + # Telling git who we are + git config --global user.email "coffee@umbraco.com" 2>&1 | % { $_.ToString() } + git config --global user.name "Umbraco HQ" 2>&1 | % { $_.ToString() } + + # Commit + CD $env:GIT_REPOSITORYNAME + Write-Host "Committing Umbraco $version Release from Build Output" + + git add . 2>&1 | % { $_.ToString() } + git commit -m " Release $releaseName from Build Output" 2>&1 | % { $_.ToString() } + + # Tag the release + git tag -a "v$version" -m "v$version" + + # Push release to master + $fullGitAuthUrl = "https://$($env:GIT_USERNAME):$GitHubPersonalAccessToken@$env:GIT_URL/$env:GIT_REPOSITORYNAME.git" + git push $fullGitAuthUrl 2>&1 | % { $_.ToString() } + + #Push tag to master + git push $fullGitAuthUrl --tags 2>&1 | % { $_.ToString() } +} +else +{ + Write-Error "Umbraco release file not found, searched in $workingDirectory\$zipsDir for a file with pattern $pattern - cancelling" +} diff --git a/build/NuSpecs/UmbracoCms.Core.nuspec b/build/NuSpecs/UmbracoCms.Core.nuspec index cbab1536b0..7775aaabbe 100644 --- a/build/NuSpecs/UmbracoCms.Core.nuspec +++ b/build/NuSpecs/UmbracoCms.Core.nuspec @@ -24,10 +24,8 @@ - - - + @@ -36,8 +34,8 @@ - - + + diff --git a/build/NuSpecs/UmbracoCms.nuspec b/build/NuSpecs/UmbracoCms.nuspec index 1b2ad2d6bf..a8fb9cf0c3 100644 --- a/build/NuSpecs/UmbracoCms.nuspec +++ b/build/NuSpecs/UmbracoCms.nuspec @@ -31,6 +31,7 @@ + diff --git a/build/NuSpecs/tools/ReadmeUpgrade.txt b/build/NuSpecs/tools/ReadmeUpgrade.txt index e85b22a902..2b6da733a1 100644 --- a/build/NuSpecs/tools/ReadmeUpgrade.txt +++ b/build/NuSpecs/tools/ReadmeUpgrade.txt @@ -8,7 +8,7 @@ ---------------------------------------------------- -*** IMPORTANT NOTICE FOR 7.7 UPGRADES *** +*** IMPORTANT NOTICE FOR UPGRADES FROM VERSIONS BELOW 7.7.0 *** Be sure to read the version specific upgrade information before proceeding: https://our.umbraco.org/documentation/Getting-Started/Setup/Upgrading/version-specific#version-7-7-0 diff --git a/build/NuSpecs/tools/install.ps1 b/build/NuSpecs/tools/install.ps1 index 0e62fb0749..592dc951e0 100644 --- a/build/NuSpecs/tools/install.ps1 +++ b/build/NuSpecs/tools/install.ps1 @@ -98,10 +98,51 @@ if ($project) { $umbracoUIXMLDestination = Join-Path $projectPath "Umbraco\Config\Create\UI.xml" Copy-Item $umbracoUIXMLSource $umbracoUIXMLDestination -Force } else { + # This part only runs for upgrades + $upgradeViewSource = Join-Path $umbracoFolderSource "Views\install\*" $upgradeView = Join-Path $umbracoFolder "Views\install\" Write-Host "Copying2 ${upgradeViewSource} to ${upgradeView}" Copy-Item $upgradeViewSource $upgradeView -Force + + Try + { + # Disable tours for upgrades, presumably Umbraco experience is already available + $umbracoSettingsConfigPath = Join-Path $configFolder "umbracoSettings.config" + $content = (Get-Content $umbracoSettingsConfigPath).Replace('','') + # Saves with UTF-8 encoding without BOM which makes sure Umbraco can still read it + # Reference: https://stackoverflow.com/a/32951824/5018 + [IO.File]::WriteAllLines($umbracoSettingsConfigPath, $content) + } + Catch + { + # Not a big problem if this fails, let it go + } + + Try + { + $uiXmlConfigPath = Join-Path $umbracoFolder -ChildPath "Config" | Join-Path -ChildPath "create" | Join-Path -ChildPath "UI.xml" + $uiXmlFile = Join-Path $umbracoFolder -ChildPath "Config" | Join-Path -ChildPath "create" | Join-Path -ChildPath "UI.xml" + + $uiXml = New-Object System.Xml.XmlDocument + $uiXml.PreserveWhitespace = $true + + $uiXml.Load($uiXmlFile) + $createExists = $uiXml.SelectNodes("//nodeType[@alias='macros']/tasks/create") + + if($createExists.Count -eq 0) + { + $macrosTasksNode = $uiXml.SelectNodes("//nodeType[@alias='macros']/tasks") + + #Creating: + $createNode = $uiXml.CreateElement("create") + $createNode.SetAttribute("assembly", "umbraco") + $createNode.SetAttribute("type", "macroTasks") + $macrosTasksNode.AppendChild($createNode) + $uiXml.Save($uiXmlFile) + } + } + Catch { } } $installFolder = Join-Path $projectPath "Install" diff --git a/build/NuSpecs/tools/trees.config.install.xdt b/build/NuSpecs/tools/trees.config.install.xdt index 65aa9c2b53..5a549e3fd8 100644 --- a/build/NuSpecs/tools/trees.config.install.xdt +++ b/build/NuSpecs/tools/trees.config.install.xdt @@ -43,7 +43,7 @@ xdt:Locator="Match(application,alias)" xdt:Transform="InsertIfMissing" /> - - - - diff --git a/build/build.ps1 b/build/build.ps1 index ae4fcb679b..20eeb1a8ea 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -328,15 +328,7 @@ $this.CopyFiles("$src\Umbraco.Web.UI\umbraco\js", "*", "$tmp\WebApp\umbraco\js") $this.CopyFiles("$src\Umbraco.Web.UI\umbraco\lib", "*", "$tmp\WebApp\umbraco\lib") $this.CopyFiles("$src\Umbraco.Web.UI\umbraco\views", "*", "$tmp\WebApp\umbraco\views") - $this.CopyFiles("$src\Umbraco.Web.UI\umbraco\preview", "*", "$tmp\WebApp\umbraco\preview") - - # prepare WebPI - Write-Host "Prepare WebPI" - $this.RemoveDirectory("$tmp\WebPi") - mkdir "$tmp\WebPi" > $null - mkdir "$tmp\WebPi\umbraco" > $null - $this.CopyFiles("$tmp\WebApp", "*", "$tmp\WebPi\umbraco") - $this.CopyFiles("$src\WebPi", "*", "$tmp\WebPi") + $this.CopyFiles("$src\Umbraco.Web.UI\umbraco\preview", "*", "$tmp\WebApp\umbraco\preview") }) $ubuild.DefineMethod("PackageZip", @@ -359,19 +351,7 @@ "$tmp\WebApp\*" ` "-x!dotless.Core.*" "-x!Content_Types.xml" "-x!*.pdb" "-x!Umbraco.Compat7.*" ` > $null - if (-not $?) { throw "Failed to zip UmbracoCms." } - - Write-Host "Zip WebPI" - &$this.BuildEnv.Zip a -r "$out\UmbracoCms.WebPI.$($this.Version.Semver).zip" "-x!*.pdb" ` - "$tmp\WebPi\*" ` - "-x!dotless.Core.*" "-x!Umbraco.Compat7.*" ` - > $null - if (-not $?) { throw "Failed to zip UmbracoCms.WebPI." } - - # hash the webpi file - Write-Host "Hash WebPI" - $hash = $this.GetFileHash("$out\UmbracoCms.WebPI.$($this.Version.Semver).zip") - Write-Output $hash | out-file "$out\webpihash.txt" -encoding ascii + if (-not $?) { throw "Failed to zip UmbracoCms." } }) $ubuild.DefineMethod("PrepareBuild", @@ -450,6 +430,12 @@ if ($this.OnError()) { return } }) + $ubuild.DefineMethod("PrepareAzureGallery", + { + Write-Host "Prepare Azure Gallery" + $this.CopyFile("$($this.SolutionRoot)\build\Azure\azuregalleryrelease.ps1", $this.BuildOutput) + }) + $ubuild.DefineMethod("Build", { $this.PrepareBuild() @@ -475,6 +461,8 @@ if ($this.OnError()) { return } $this.PackageNuGet() if ($this.OnError()) { return } + $this.PrepareAzureGallery() + if ($this.OnError()) { return } }) # ################################################################ diff --git a/src/NuGet.Config b/src/NuGet.Config index dcccfdd5c1..89e79db976 100644 --- a/src/NuGet.Config +++ b/src/NuGet.Config @@ -2,6 +2,6 @@ - + \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/bower.json b/src/Umbraco.Web.UI.Client/bower.json index 236527907c..2aaa4fc1d4 100644 --- a/src/Umbraco.Web.UI.Client/bower.json +++ b/src/Umbraco.Web.UI.Client/bower.json @@ -26,63 +26,48 @@ "jquery-migrate": "1.4.0", "angular-dynamic-locale": "0.1.28", "ng-file-upload": "~7.3.8", - "tinymce": "~4.5.3", + "tinymce": "~4.7.1", "codemirror": "~5.3.0", "angular-local-storage": "~0.2.3", "moment": "~2.10.3", "ace-builds": "^1.2.3", - "font-awesome": "~4.2", - "clipboard": "1.7.1" + "clipboard": "1.7.1", + "font-awesome": "~4.2" }, - "install": { - "path": "lib-bower", - "ignore": [ "font-awesome", "angular", "bootstrap", "codemirror" ], - "sources": { "moment": "bower_components/moment/min/moment-with-locales.js", - "underscore": [ "bower_components/underscore/underscore-min.js", "bower_components/underscore/underscore-min.map" ], - "jquery": [ "bower_components/jquery/dist/jquery.min.js", "bower_components/jquery/dist/jquery.min.map" ], - "angular-dynamic-locale": [ "bower_components/angular-dynamic-locale/tmhDynamicLocale.min.js", "bower_components/angular-dynamic-locale/tmhDynamicLocale.min.js.map" ], - "angular-local-storage": [ "bower_components/angular-local-storage/dist/angular-local-storage.min.js", "bower_components/angular-local-storage/dist/angular-local-storage.min.js.map" ], - "tinymce": [ "bower_components/tinymce/tinymce.min.js" ], - "typeahead.js": "bower_components/typeahead.js/dist/typeahead.bundle.min.js", - - "rgrove-lazyload":"bower_components/rgrove-lazyload/lazyload.js", - - "ng-file-upload":"bower_components/ng-file-upload/ng-file-upload.min.js", - - "jquery-ui":"bower_components/jquery-ui/jquery-ui.min.js", - - "jquery-migrate":"bower_components/jquery-migrate/jquery-migrate.min.js", - + "rgrove-lazyload": "bower_components/rgrove-lazyload/lazyload.js", + "ng-file-upload": "bower_components/ng-file-upload/ng-file-upload.min.js", + "jquery-ui": "bower_components/jquery-ui/jquery-ui.min.js", + "jquery-migrate": "bower_components/jquery-migrate/jquery-migrate.min.js", "clipboard": "bower_components/clipboard/dist/clipboard.min.js" } } diff --git a/src/Umbraco.Web.UI.Client/lib/bootstrap/less/thumbnails.less b/src/Umbraco.Web.UI.Client/lib/bootstrap/less/thumbnails.less index 513039cca4..c81b6398ed 100644 --- a/src/Umbraco.Web.UI.Client/lib/bootstrap/less/thumbnails.less +++ b/src/Umbraco.Web.UI.Client/lib/bootstrap/less/thumbnails.less @@ -31,15 +31,17 @@ display: block; padding: 4px; line-height: @baseLineHeight; - border: 1px solid #ddd; + border: 1px solid @gray-8; .border-radius(@baseBorderRadius); .box-shadow(0 1px 3px rgba(0,0,0,.055)); .transition(all .2s ease-in-out); } -// Add a hover/focus state for linked versions only -a.thumbnail:hover, -a.thumbnail:focus { - border-color: @linkColor; +// Add a hover/focus state for linked versions only. +a.thumbnail:hover, +a.thumbnail:focus, +a div.thumbnail:hover, +a div.thumbnail:focus { + border-color: @turquoise; .box-shadow(0 1px 4px rgba(0,105,214,.25)); } diff --git a/src/Umbraco.Web.UI.Client/lib/umbraco/LegacyUmbClientMgr.js b/src/Umbraco.Web.UI.Client/lib/umbraco/LegacyUmbClientMgr.js index cc2b949d22..84651510be 100644 --- a/src/Umbraco.Web.UI.Client/lib/umbraco/LegacyUmbClientMgr.js +++ b/src/Umbraco.Web.UI.Client/lib/umbraco/LegacyUmbClientMgr.js @@ -350,11 +350,29 @@ Umbraco.Sys.registerNamespace("Umbraco.Application"); rootScope : function(){ return getRootScope(); }, - - reloadLocation: function() { - var injector = getRootInjector(); - var $route = injector.get("$route"); - $route.reload(); + + /** + This will reload the content frame based on it's current route, if pathToMatch is specified it will only reload it if the current + location matches the path + */ + reloadLocation: function(pathToMatch) { + + var injector = getRootInjector(); + var doChange = true; + if (pathToMatch) { + var $location = injector.get("$location"); + var path = $location.path(); + if (path != pathToMatch) { + doChange = false; + } + } + + if (doChange) { + var $route = injector.get("$route"); + $route.reload(); + var $rootScope = injector.get("$rootScope"); + $rootScope.$apply(); + } }, closeModalWindow: function(rVal) { diff --git a/src/Umbraco.Web.UI.Client/src/app.js b/src/Umbraco.Web.UI.Client/src/app.js index e7b7288f43..4a44959b15 100644 --- a/src/Umbraco.Web.UI.Client/src/app.js +++ b/src/Umbraco.Web.UI.Client/src/app.js @@ -11,7 +11,7 @@ var app = angular.module('umbraco', [ 'ngMobile', 'tmh.dynamicLocale', 'ngFileUpload', - 'LocalStorageModule' + 'LocalStorageModule' ]); var packages = angular.module("umbraco.packages", []); @@ -22,12 +22,21 @@ var packages = angular.module("umbraco.packages", []); //module is initilized. angular.module("umbraco.views", ["umbraco.viewcache"]); angular.module("umbraco.viewcache", []) - .run(function($rootScope, $templateCache) { + .run(function ($rootScope, $templateCache, localStorageService) { /** For debug mode, always clear template cache to cut down on dev frustration and chrome cache on templates */ if (Umbraco.Sys.ServerVariables.isDebuggingEnabled) { $templateCache.removeAll(); } + else { + var storedVersion = localStorageService.get("umbVersion"); + if (!storedVersion || storedVersion !== Umbraco.Sys.ServerVariables.application.cacheBuster) { + //if the stored version doesn't match our cache bust version, clear the template cache + $templateCache.removeAll(); + //store the current version + localStorageService.set("umbVersion", Umbraco.Sys.ServerVariables.application.cacheBuster); + } + } }) .config([ //This ensures that all of our angular views are cache busted, if the path starts with views/ and ends with .html, then diff --git a/src/Umbraco.Web.UI.Client/src/canvasdesigner/canvasdesigner.controller.js b/src/Umbraco.Web.UI.Client/src/canvasdesigner/canvasdesigner.controller.js index 1a1ad6b325..6a8d7564dc 100644 --- a/src/Umbraco.Web.UI.Client/src/canvasdesigner/canvasdesigner.controller.js +++ b/src/Umbraco.Web.UI.Client/src/canvasdesigner/canvasdesigner.controller.js @@ -7,12 +7,22 @@ var app = angular.module("Umbraco.canvasdesigner", ['colorpicker', 'ui.slider', .controller("Umbraco.canvasdesignerController", function ($scope, $http, $window, $timeout, $location, dialogService) { + var isInit = $location.search().init; + if (isInit === "true") { + //do not continue, this is the first load of this new window, if this is passed in it means it's been + //initialized by the content editor and then the content editor will actually re-load this window without + //this flag. This is a required trick to get around chrome popup mgr. We don't want to double load preview.aspx + //since that will double prepare the preview documents + return; + } + $scope.isOpen = false; $scope.frameLoaded = false; $scope.enableCanvasdesigner = 0; $scope.googleFontFamilies = {}; - $scope.pageId = $location.search().id; - $scope.pageUrl = "../dialogs/Preview.aspx?id=" + $location.search().id; + var pageId = $location.search().id; + $scope.pageId = pageId; + $scope.pageUrl = "../dialogs/Preview.aspx?id=" + pageId; $scope.valueAreLoaded = false; $scope.devices = [ { name: "desktop", css: "desktop", icon: "icon-display", title: "Desktop" }, diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbbackdrop.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbbackdrop.directive.js new file mode 100644 index 0000000000..ced59653dd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbbackdrop.directive.js @@ -0,0 +1,116 @@ +(function () { + "use strict"; + + function BackdropDirective($timeout, $http) { + + function link(scope, el, attr, ctrl) { + + var events = []; + + scope.clickBackdrop = function(event) { + if(scope.disableEventsOnClick === true) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + function onInit() { + + if (scope.highlightElement) { + setHighlight(); + } + + } + + function setHighlight () { + + scope.loading = true; + + $timeout(function () { + + // The element to highlight + var highlightElement = angular.element(scope.highlightElement); + + if(highlightElement && highlightElement.length > 0) { + + var offset = highlightElement.offset(); + var width = highlightElement.outerWidth(); + var height = highlightElement.outerHeight(); + + // Rounding numbers + var topDistance = offset.top.toFixed(); + var topAndHeight = (offset.top + height).toFixed(); + var leftDistance = offset.left.toFixed(); + var leftAndWidth = (offset.left + width).toFixed(); + + // The four rectangles + var rectTop = el.find(".umb-backdrop__rect--top"); + var rectRight = el.find(".umb-backdrop__rect--right"); + var rectBottom = el.find(".umb-backdrop__rect--bottom"); + var rectLeft = el.find(".umb-backdrop__rect--left"); + + // Add the css + scope.rectTopCss = { "height": topDistance, "left": leftDistance + "px", opacity: scope.backdropOpacity }; + scope.rectRightCss = { "left": leftAndWidth + "px", "top": topDistance + "px", "height": height, opacity: scope.backdropOpacity }; + scope.rectBottomCss = { "height": "100%", "top": topAndHeight + "px", "left": leftDistance + "px", opacity: scope.backdropOpacity }; + scope.rectLeftCss = { "width": leftDistance, opacity: scope.backdropOpacity }; + + // Prevent interaction in the highlighted area + if(scope.highlightPreventClick) { + var preventClickElement = el.find(".umb-backdrop__highlight-prevent-click"); + preventClickElement.css({ "width": width, "height": height, "left": offset.left, "top": offset.top }); + } + + } + + scope.loading = false; + + }); + + } + + function resize() { + setHighlight(); + } + + events.push(scope.$watch("highlightElement", function (newValue, oldValue) { + if(!newValue) {return;} + if(newValue === oldValue) {return;} + setHighlight(); + })); + + $(window).on("resize.umbBackdrop", resize); + + scope.$on("$destroy", function () { + // unbind watchers + for (var e in events) { + events[e](); + } + $(window).off("resize.umbBackdrop"); + }); + + onInit(); + + } + + var directive = { + transclude: true, + restrict: "E", + replace: true, + templateUrl: "views/components/application/umb-backdrop.html", + link: link, + scope: { + backdropOpacity: "=?", + highlightElement: "=?", + highlightPreventClick: "=?", + disableEventsOnClick: "=?", + } + }; + + return directive; + + } + + angular.module("umbraco.directives").directive("umbBackdrop", BackdropDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawer.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawer.directive.js new file mode 100644 index 0000000000..e2e94e466f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawer.directive.js @@ -0,0 +1,109 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbDrawer +@restrict E +@scope + +@description +The drawer component is a global component and is already added to the umbraco markup. It is registered in globalState and can be opened and configured by raising events. + +

Markup example - how to open the drawer

+
+    
+ + + + +
+
+ +

Controller example - how to open the drawer

+
+    (function () {
+        "use strict";
+
+        function DrawerController(appState) {
+
+            var vm = this;
+
+            vm.toggleDrawer = toggleDrawer;
+
+            function toggleDrawer() {
+
+                var showDrawer = appState.getDrawerState("showDrawer");            
+
+                var model = {
+                    firstName: "Super",
+                    lastName: "Man"
+                };
+
+                appState.setDrawerState("view", "/App_Plugins/path/to/drawer.html");
+                appState.setDrawerState("model", model);
+                appState.setDrawerState("showDrawer", !showDrawer);
+                
+            }
+
+        }
+
+        angular.module("umbraco").controller("My.DrawerController", DrawerController);
+
+    })();
+
+ +

Use the following components in the custom drawer to render the content

+
    +
  • {@link umbraco.directives.directive:umbDrawerView umbDrawerView}
  • +
  • {@link umbraco.directives.directive:umbDrawerHeader umbDrawerHeader}
  • +
  • {@link umbraco.directives.directive:umbDrawerView umbDrawerContent}
  • +
  • {@link umbraco.directives.directive:umbDrawerFooter umbDrawerFooter}
  • +
+ +@param {string} view (binding): Set the drawer view +@param {string} model (binding): Pass in custom data to the drawer + +**/ + +function Drawer($location, $routeParams, helpService, userService, localizationService, dashboardResource) { + + return { + + restrict: "E", // restrict to an element + replace: true, // replace the html element with the template + templateUrl: 'views/components/application/umbdrawer/umb-drawer.html', + transclude: true, + scope: { + view: "=?", + model: "=?" + }, + + link: function (scope, element, attr, ctrl) { + + function onInit() { + setView(); + } + + function setView() { + if (scope.view) { + //we do this to avoid a hidden dialog to start loading unconfigured views before the first activation + var configuredView = scope.view; + if (scope.view.indexOf(".html") === -1) { + var viewAlias = scope.view.toLowerCase(); + configuredView = "views/common/drawers/" + viewAlias + "/" + viewAlias + ".html"; + } + if (configuredView !== scope.configuredView) { + scope.configuredView = configuredView; + } + } + } + + onInit(); + + } + + }; + } + + angular.module('umbraco.directives').directive("umbDrawer", Drawer); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawercontent.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawercontent.directive.js new file mode 100644 index 0000000000..d4aa76f70e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawercontent.directive.js @@ -0,0 +1,59 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbDrawerContent +@restrict E +@scope + +@description +Use this directive to render drawer content + +

Markup example

+
+	
+        
+        
+        
+
+        
+            
+            
{{ model | json }}
+
+ + + + + +
+
+ + +

Use in combination with

+
    +
  • {@link umbraco.directives.directive:umbDrawerView umbDrawerView}
  • +
  • {@link umbraco.directives.directive:umbDrawerHeader umbDrawerHeader}
  • +
  • {@link umbraco.directives.directive:umbDrawerFooter umbDrawerFooter}
  • +
+ +**/ + +(function() { + 'use strict'; + + function DrawerContentDirective() { + + var directive = { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: 'views/components/application/umbdrawer/umb-drawer-content.html' + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbDrawerContent', DrawerContentDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerfooter.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerfooter.directive.js new file mode 100644 index 0000000000..f76bca3a77 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerfooter.directive.js @@ -0,0 +1,58 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbDrawerFooter +@restrict E +@scope + +@description +Use this directive to render a drawer footer + +

Markup example

+
+	
+        
+        
+        
+
+        
+            
+            
{{ model | json }}
+
+ + + + + +
+
+ +

Use in combination with

+
    +
  • {@link umbraco.directives.directive:umbDrawerView umbDrawerView}
  • +
  • {@link umbraco.directives.directive:umbDrawerHeader umbDrawerHeader}
  • +
  • {@link umbraco.directives.directive:umbDrawerContent umbDrawerContent}
  • +
+ +**/ + +(function() { + 'use strict'; + + function DrawerFooterDirective() { + + var directive = { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: 'views/components/application/umbdrawer/umb-drawer-footer.html' + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbDrawerFooter', DrawerFooterDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerheader.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerheader.directive.js new file mode 100644 index 0000000000..78237a539e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerheader.directive.js @@ -0,0 +1,63 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbDrawerHeader +@restrict E +@scope + +@description +Use this directive to render a drawer header + +

Markup example

+
+	
+        
+        
+        
+
+        
+            
+            
{{ model | json }}
+
+ + + + + +
+
+ +

Use in combination with

+
    +
  • {@link umbraco.directives.directive:umbDrawerView umbDrawerView}
  • +
  • {@link umbraco.directives.directive:umbDrawerContent umbDrawerContent}
  • +
  • {@link umbraco.directives.directive:umbDrawerFooter umbDrawerFooter}
  • +
+ +@param {string} title (attribute): Set a drawer title. +@param {string} description (attribute): Set a drawer description. +**/ + +(function() { + 'use strict'; + + function DrawerHeaderDirective() { + + var directive = { + restrict: 'E', + replace: true, + templateUrl: 'views/components/application/umbdrawer/umb-drawer-header.html', + scope: { + "title": "@?", + "description": "@?" + } + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbDrawerHeader', DrawerHeaderDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerview.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerview.directive.js new file mode 100644 index 0000000000..54cfea0857 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbdrawer/umbdrawerview.directive.js @@ -0,0 +1,58 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbDrawerView +@restrict E +@scope + +@description +Use this directive to render drawer view + +

Markup example

+
+	
+        
+        
+        
+
+        
+            
+            
{{ model | json }}
+
+ + + + + +
+
+ +

Use in combination with

+
    +
  • {@link umbraco.directives.directive:umbDrawerHeader umbDrawerHeader}
  • +
  • {@link umbraco.directives.directive:umbDrawerContent umbDrawerContent}
  • +
  • {@link umbraco.directives.directive:umbDrawerFooter umbDrawerFooter}
  • +
+ +**/ + +(function() { + 'use strict'; + + function DrawerViewDirective() { + + var directive = { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: 'views/components/application/umbdrawer/umb-drawer-view.html' + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbDrawerView', DrawerViewDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbsections.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbsections.directive.js index 436dcfcd0d..87e0cb62f4 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbsections.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbsections.directive.js @@ -3,7 +3,7 @@ * @name umbraco.directives.directive:umbSections * @restrict E **/ -function sectionsDirective($timeout, $window, navigationService, treeService, sectionService, appState, eventsService, $location) { +function sectionsDirective($timeout, $window, navigationService, treeService, sectionService, appState, eventsService, $location, historyService) { return { restrict: "E", // restrict to an element replace: true, // replace the html element with the template @@ -111,30 +111,13 @@ function sectionsDirective($timeout, $window, navigationService, treeService, se scope.userDialog = null; } - scope.helpClick = function(){ - - if(scope.userDialog) { - closeUserDialog(); - } - - if(!scope.helpDialog) { - scope.helpDialog = { - view: "help", - show: true, - close: function(oldModel) { - closeHelpDialog(); - } - }; - } else { - closeHelpDialog(); - } - - }; - - function closeHelpDialog() { - scope.helpDialog.show = false; - scope.helpDialog = null; - } + //toggle the help dialog by raising the global app state to toggle the help drawer + scope.helpClick = function () { + var showDrawer = appState.getDrawerState("showDrawer"); + var drawer = { view: "help", show: !showDrawer }; + appState.setDrawerState("view", drawer.view); + appState.setDrawerState("showDrawer", drawer.show); + }; scope.sectionClick = function (event, section) { @@ -149,19 +132,19 @@ function sectionsDirective($timeout, $window, navigationService, treeService, se if (scope.userDialog) { closeUserDialog(); } - if (scope.helpDialog) { - closeHelpDialog(); - } + navigationService.hideSearch(); - navigationService.showTree(section.alias); + navigationService.showTree(section.alias); //in some cases the section will have a custom route path specified, if there is one we'll use it if (section.routePath) { $location.path(section.routePath); } else { - $location.path(section.alias).search(''); + var lastAccessed = historyService.getLastAccessedItemForSection(section.alias); + var path = lastAccessed != null ? lastAccessed.link : section.alias; + $location.path(path).search(''); } }; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour.directive.js new file mode 100644 index 0000000000..6dc066b282 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour.directive.js @@ -0,0 +1,552 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbTour +@restrict E +@scope + +@description +Added in Umbraco 7.8. The tour component is a global component and is already added to the umbraco markup. +In the Umbraco UI the tours live in the "Help drawer" which opens when you click the Help-icon in the bottom left corner of Umbraco. +You can easily add you own tours to the Help-drawer or show and start tours from +anywhere in the Umbraco backoffice. To see a real world example of a custom tour implementation, install The Starter Kit in Umbraco 7.8 + +

Extending the help drawer with custom tours

+The easiet way to add new tours to Umbraco is through the Help-drawer. All it requires is a my-tour.json file. +Place the file in App_Plugins/{MyPackage}/backoffice/tours/{my-tour}.json and it will automatically be +picked up by Umbraco and shown in the Help-drawer. + +

The tour object

+The tour object consist of two parts - The overall tour configuration and a list of tour steps. We have split up the tour object for a better overview. +
+// The tour config object
+{
+    "name": "My Custom Tour", // (required)
+    "alias": "myCustomTour", // A unique tour alias (required)
+    "group": "My Custom Group" // Used to group tours in the help drawer
+    "groupOrder": 200 // Control the order of tour groups
+    "allowDisable": // Adds a "Don't" show this tour again"-button to the intro step
+    "requiredSections":["content", "media", "mySection"] // Sections that the tour will access while running, if the user does not have access to the required tour sections, the tour will not load.   
+    "steps": [] // tour steps - see next example
+}
+
+
+// A tour step object
+{
+    "title": "Title",
+    "content": "

Step content

", + "type": "intro" // makes the step an introduction step, + "element": "[data-element='my-table-row']", // the highlighted element + "event": "click" // forces the user to click the UI to go to next step + "eventElement": "[data-element='my-table-row'] [data-element='my-tour-button']" // specify an element to click inside a highlighted element + "elementPreventClick": false // prevents user interaction in the highlighted element + "backdropOpacity": 0.4 // the backdrop opacity + "view": "" // add a custom view + "customProperties" : {} // add any custom properties needed for the custom view +} +
+ +

Adding tours to other parts of the Umbraco backoffice

+It is also possible to add a list of custom tours to other parts of the Umbraco backoffice, +as an example on a Dashboard in a Custom section. You can then use the {@link umbraco.services.tourService tourService} to start and stop tours but you don't have to register them as part of the tour service. + +

Using the tour service

+

Markup example - show custom tour

+
+    
+ +
{{vm.tour.name}}
+ + + + + +
+
+ +

Controller example - show custom tour

+
+    (function () {
+        "use strict";
+
+        function TourController(tourService) {
+
+            var vm = this;
+
+            vm.tour = {
+                "name": "My Custom Tour",
+                "alias": "myCustomTour",
+                "steps": [
+                    {
+                        "title": "Welcome to My Custom Tour",
+                        "content": "",
+                        "type": "intro"
+                    },
+                    {
+                        "element": "[data-element='my-tour-button']",
+                        "title": "Click the button",
+                        "content": "Click the button",
+                        "event": "click"
+                    }
+                ]
+            };
+
+            vm.startTour = startTour;
+
+            function startTour() {
+                tourService.startTour(vm.tour);
+            }
+
+        }
+
+        angular.module("umbraco").controller("My.TourController", TourController);
+
+    })();
+
+ +

Custom step views

+In some cases you will need a custom view for one of your tour steps. +This could be for validation or for running any other custom logic for that step. +We have added a couple of helper components to make it easier to get the step scaffolding to look like a regular tour step. +In the following example you see how to run some custom logic before a step goes to the next step. + +

Markup example - custom step view

+
+    
+ + + + + + + + + + + + + + + + + +
+ + +
+ +
+ +
+ +
+
+ +

Controller example - custom step view

+
+    (function () {
+        "use strict";
+
+        function StepController() {
+
+            var vm = this;
+            
+            vm.initNextStep = initNextStep;
+
+            function initNextStep() {
+                // run logic here before going to the next step
+                $scope.model.nextStep();
+            }
+
+        }
+
+        angular.module("umbraco").controller("My.TourStep", StepController);
+
+    })();
+
+ + +

Related services

+
    +
  • {@link umbraco.services.tourService tourService}
  • +
+ +@param {string} model (binding): Tour object + +**/ + +(function () { + 'use strict'; + + function TourDirective($timeout, $http, $q, tourService, backdropService) { + + function link(scope, el, attr, ctrl) { + + var popover; + var pulseElement; + var pulseTimer; + + scope.loadingStep = false; + scope.elementNotFound = false; + + scope.model.nextStep = function() { + nextStep(); + }; + + scope.model.endTour = function() { + unbindEvent(); + tourService.endTour(scope.model); + backdropService.close(); + }; + + scope.model.completeTour = function() { + unbindEvent(); + tourService.completeTour(scope.model).then(function() { + backdropService.close(); + }); + }; + + scope.model.disableTour = function() { + unbindEvent(); + tourService.disableTour(scope.model).then(function() { + backdropService.close(); + }); + } + + function onInit() { + popover = el.find(".umb-tour__popover"); + pulseElement = el.find(".umb-tour__pulse"); + popover.hide(); + scope.model.currentStepIndex = 0; + backdropService.open({disableEventsOnClick: true}); + startStep(); + } + + function setView() { + if (scope.model.currentStep.view && scope.model.alias) { + //we do this to avoid a hidden dialog to start loading unconfigured views before the first activation + var configuredView = scope.model.currentStep.view; + if (scope.model.currentStep.view.indexOf(".html") === -1) { + var viewAlias = scope.model.currentStep.view.toLowerCase(); + var tourAlias = scope.model.alias.toLowerCase(); + configuredView = "views/common/tours/" + tourAlias + "/" + viewAlias + "/" + viewAlias + ".html"; + } + if (configuredView !== scope.configuredView) { + scope.configuredView = configuredView; + } + } else { + scope.configuredView = null; + } + } + + function nextStep() { + + popover.hide(); + pulseElement.hide(); + $timeout.cancel(pulseTimer); + scope.model.currentStepIndex++; + + // make sure we don't go too far + if(scope.model.currentStepIndex !== scope.model.steps.length) { + startStep(); + // tour completed - final step + } else { + scope.loadingStep = true; + + waitForPendingRerequests().then(function(){ + scope.loadingStep = false; + // clear current step + scope.model.currentStep = {}; + // set popover position to center + setPopoverPosition(null); + // remove backdrop hightlight and custom opacity + backdropService.setHighlight(null); + backdropService.setOpacity(null); + }); + } + } + + function startStep() { + scope.loadingStep = true; + backdropService.setOpacity(scope.model.steps[scope.model.currentStepIndex].backdropOpacity); + backdropService.setHighlight(null); + + waitForPendingRerequests().then(function() { + + scope.model.currentStep = scope.model.steps[scope.model.currentStepIndex]; + + setView(); + + // if highlight element is set - find it + findHighlightElement(); + + // if a custom event needs to be bound we do it now + if(scope.model.currentStep.event) { + bindEvent(); + } + + scope.loadingStep = false; + + }); + } + + function findHighlightElement() { + + scope.elementNotFound = false; + + $timeout(function () { + + // if an element isn't set - show the popover in the center + if(scope.model.currentStep && !scope.model.currentStep.element) { + setPopoverPosition(null); + return; + } + + var element = angular.element(scope.model.currentStep.element); + + // we couldn't find the element in the dom - abort and show error + if(element.length === 0) { + scope.elementNotFound = true; + setPopoverPosition(null); + return; + } + + var scrollParent = element.scrollParent(); + var scrollToCenterOfContainer = element[0].offsetTop - (scrollParent[0].clientHeight / 2 ) + (element[0].clientHeight / 2); + + // Detect if scroll is needed + if (element[0].offsetTop > scrollParent[0].clientHeight) { + scrollParent.animate({ + scrollTop: scrollToCenterOfContainer + }, function () { + // Animation complete. + setPopoverPosition(element); + setPulsePosition(); + backdropService.setHighlight(scope.model.currentStep.element, scope.model.currentStep.elementPreventClick); + }); + } else { + setPopoverPosition(element); + setPulsePosition(); + backdropService.setHighlight(scope.model.currentStep.element, scope.model.currentStep.elementPreventClick); + } + + }); + + } + + function setPopoverPosition(element) { + + $timeout(function () { + + var position = "center"; + var margin = 20; + var css = {}; + + var popoverWidth = popover.outerWidth(); + var popoverHeight = popover.outerHeight(); + var popoverOffset = popover.offset(); + var documentWidth = angular.element(document).width(); + var documentHeight = angular.element(document).height(); + + if(element) { + + var offset = element.offset(); + var width = element.outerWidth(); + var height = element.outerHeight(); + + // messure available space on each side of the target element + var space = { + "top": offset.top, + "right": documentWidth - (offset.left + width), + "bottom": documentHeight - (offset.top + height), + "left": offset.left + }; + + // get the posistion with most available space + position = findMax(space); + + if (position === "top") { + if (offset.left < documentWidth / 2) { + css.top = offset.top - popoverHeight - margin; + css.left = offset.left; + } else { + css.top = offset.top - popoverHeight - margin; + css.left = offset.left - popoverWidth + width; + } + } + + if (position === "right") { + if (offset.top < documentHeight / 2) { + css.top = offset.top; + css.left = offset.left + width + margin; + } else { + css.top = offset.top + height - popoverHeight; + css.left = offset.left + width + margin; + } + } + + if (position === "bottom") { + if (offset.left < documentWidth / 2) { + css.top = offset.top + height + margin; + css.left = offset.left; + } else { + css.top = offset.top + height + margin; + css.left = offset.left - popoverWidth + width; + } + } + + if (position === "left") { + if (offset.top < documentHeight / 2) { + css.top = offset.top; + css.left = offset.left - popoverWidth - margin; + } else { + css.top = offset.top + height - popoverHeight; + css.left = offset.left - popoverWidth - margin; + } + } + + } else { + // if there is no dom element center the popover + css.top = "calc(50% - " + popoverHeight/2 + "px)"; + css.left = "calc(50% - " + popoverWidth/2 + "px)"; + } + + popover.css(css).fadeIn("fast"); + + }); + + + } + + function setPulsePosition() { + if(scope.model.currentStep.event) { + + pulseTimer = $timeout(function(){ + + var clickElementSelector = scope.model.currentStep.eventElement ? scope.model.currentStep.eventElement : scope.model.currentStep.element; + var clickElement = $(clickElementSelector); + + var offset = clickElement.offset(); + var width = clickElement.outerWidth(); + var height = clickElement.outerHeight(); + + pulseElement.css({ "width": width, "height": height, "left": offset.left, "top": offset.top }); + pulseElement.fadeIn(); + + }, 1000); + } + } + + function waitForPendingRerequests() { + var deferred = $q.defer(); + var timer = window.setInterval(function(){ + // check for pending requests both in angular and on the document + if($http.pendingRequests.length === 0 && document.readyState === "complete") { + $timeout(function(){ + deferred.resolve(); + clearInterval(timer); + }); + } + }, 50); + return deferred.promise; + } + + function findMax(obj) { + var keys = Object.keys(obj); + var max = keys[0]; + for (var i = 1, n = keys.length; i < n; ++i) { + var k = keys[i]; + if (obj[k] > obj[max]) { + max = k; + } + } + return max; + } + + function bindEvent() { + + var bindToElement = scope.model.currentStep.element; + var eventName = scope.model.currentStep.event + ".step-" + scope.model.currentStepIndex; + var removeEventName = "remove.step-" + scope.model.currentStepIndex; + var handled = false; + + if(scope.model.currentStep.eventElement) { + bindToElement = scope.model.currentStep.eventElement; + } + + $(bindToElement).on(eventName, function(){ + if(!handled) { + unbindEvent(); + nextStep(); + handled = true; + } + }); + + // Hack: we do this to handle cases where ng-if is used and removes the element we need to click. + // for some reason it seems the elements gets removed before the event is raised. This is a temp solution which assumes: + // "if you ask me to click on an element, and it suddenly gets removed from the dom, let's go on to the next step". + $(bindToElement).on(removeEventName, function () { + if(!handled) { + unbindEvent(); + nextStep(); + handled = true; + } + }); + + } + + function unbindEvent() { + var eventName = scope.model.currentStep.event + ".step-" + scope.model.currentStepIndex; + var removeEventName = "remove.step-" + scope.model.currentStepIndex; + + if(scope.model.currentStep.eventElement) { + angular.element(scope.model.currentStep.eventElement).off(eventName); + angular.element(scope.model.currentStep.eventElement).off(removeEventName); + } else { + angular.element(scope.model.currentStep.element).off(eventName); + angular.element(scope.model.currentStep.element).off(removeEventName); + } + } + + function resize() { + findHighlightElement(); + } + + onInit(); + + $(window).on('resize.umbTour', resize); + + scope.$on('$destroy', function () { + $(window).off('resize.umbTour'); + unbindEvent(); + $timeout.cancel(pulseTimer); + }); + + } + + var directive = { + transclude: true, + restrict: 'E', + replace: true, + templateUrl: 'views/components/application/umb-tour.html', + link: link, + scope: { + model: "=" + } + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbTour', TourDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstep.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstep.directive.js new file mode 100644 index 0000000000..08e0a44c0b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstep.directive.js @@ -0,0 +1,35 @@ +(function() { + 'use strict'; + + function TourStepDirective() { + + function link(scope, element, attrs, ctrl) { + + scope.close = function() { + if(scope.onClose) { + scope.onClose(); + } + } + + } + + var directive = { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: 'views/components/application/umbtour/umb-tour-step.html', + scope: { + size: "@?", + onClose: "&?", + hideClose: "=?" + }, + link: link + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbTourStep', TourStepDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcontent.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcontent.directive.js new file mode 100644 index 0000000000..52ed358b61 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcontent.directive.js @@ -0,0 +1,22 @@ +(function() { + 'use strict'; + + function TourStepContentDirective() { + + var directive = { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: 'views/components/application/umbtour/umb-tour-step-content.html', + scope: { + content: "=" + } + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbTourStepContent', TourStepContentDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcounter.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcounter.directive.js new file mode 100644 index 0000000000..7e04ef5d00 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepcounter.directive.js @@ -0,0 +1,22 @@ +(function() { + 'use strict'; + + function TourStepCounterDirective() { + + var directive = { + restrict: 'E', + replace: true, + templateUrl: 'views/components/application/umbtour/umb-tour-step-counter.html', + scope: { + currentStep: "=", + totalSteps: "=" + } + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbTourStepCounter', TourStepCounterDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepfooter.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepfooter.directive.js new file mode 100644 index 0000000000..fedb527972 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepfooter.directive.js @@ -0,0 +1,19 @@ +(function() { + 'use strict'; + + function TourStepFooterDirective() { + + var directive = { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: 'views/components/application/umbtour/umb-tour-step-footer.html' + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbTourStepFooter', TourStepFooterDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepheader.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepheader.directive.js new file mode 100644 index 0000000000..9d32ad87a4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbtour/umbtourstepheader.directive.js @@ -0,0 +1,22 @@ +(function() { + 'use strict'; + + function TourStepHeaderDirective() { + + var directive = { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: 'views/components/application/umbtour/umb-tour-step-header.html', + scope: { + title: "=" + } + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbTourStepHeader', TourStepHeaderDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js index b503648317..4b0bdb6c71 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js @@ -149,7 +149,8 @@ Use this directive to render an umbraco button. The directive can be used to gen labelKey: "@?", icon: "@?", disabled: "=", - size: "@?" + size: "@?", + alias: "@?" } }; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js index 7d60a83b89..1ecb2d7403 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js @@ -1,7 +1,9 @@ (function () { 'use strict'; - function ContentEditController($rootScope, $scope, $routeParams, $q, $timeout, $window, appState, contentResource, entityResource, navigationService, notificationsService, angularHelper, serverValidationManager, contentEditingHelper, treeService, fileManager, formHelper, umbRequestHelper, keyboardService, umbModelMapper, editorState, $http) { + function ContentEditController($rootScope, $scope, $routeParams, $q, $timeout, $window, $location, appState, contentResource, entityResource, navigationService, notificationsService, angularHelper, serverValidationManager, contentEditingHelper, treeService, fileManager, formHelper, umbRequestHelper, keyboardService, umbModelMapper, editorState, $http, eventsService, relationResource) { + + var evts = []; //setup scope vars $scope.defaultButton = null; @@ -14,22 +16,13 @@ $scope.page.menu.currentSection = appState.getSectionState("currentSection"); $scope.page.listViewPath = null; $scope.page.isNew = $scope.isNew ? true : false; - $scope.page.buttonGroupState = "init"; + $scope.page.buttonGroupState = "init"; + $scope.allowOpen = true; + function init(content) { - var buttons = contentEditingHelper.configureContentEditorButtons({ - create: $scope.page.isNew, - content: content, - methods: { - saveAndPublish: $scope.saveAndPublish, - sendToPublish: $scope.sendToPublish, - save: $scope.save, - unPublish: $scope.unPublish - } - }); - $scope.defaultButton = buttons.defaultButton; - $scope.subButtons = buttons.subButtons; + createButtons(content); editorState.set($scope.content); @@ -42,6 +35,70 @@ }); } } + + evts.push(eventsService.on("editors.content.changePublishDate", function (event, args) { + createButtons(args.node); + })); + + evts.push(eventsService.on("editors.content.changeUnpublishDate", function (event, args) { + createButtons(args.node); + })); + + // We don't get the info tab from the server from version 7.8 so we need to manually add it + contentEditingHelper.addInfoTab($scope.content.tabs); + + } + + function getNode() { + + $scope.page.loading = true; + + //we are editing so get the content item from the server + $scope.getMethod()($scope.contentId) + .then(function (data) { + + $scope.content = data; + + if (data.isChildOfListView && data.trashed === false) { + $scope.page.listViewPath = ($routeParams.page) ? + "/content/content/edit/" + data.parentId + "?page=" + $routeParams.page : + "/content/content/edit/" + data.parentId; + } + + init($scope.content); + + //in one particular special case, after we've created a new item we redirect back to the edit + // route but there might be server validation errors in the collection which we need to display + // after the redirect, so we will bind all subscriptions which will show the server validation errors + // if there are any and then clear them so the collection no longer persists them. + serverValidationManager.executeAndClearAllSubscriptions(); + + syncTreeNode($scope.content, data.path, true); + + resetLastListPageNumber($scope.content); + + $scope.page.loading = false; + + }); + + } + + function createButtons(content) { + $scope.page.buttonGroupState = "init"; + var buttons = contentEditingHelper.configureContentEditorButtons({ + create: $scope.page.isNew, + content: content, + methods: { + saveAndPublish: $scope.saveAndPublish, + sendToPublish: $scope.sendToPublish, + save: $scope.save, + unPublish: $scope.unPublish + } + }); + + $scope.defaultButton = buttons.defaultButton; + $scope.subButtons = buttons.subButtons; + } /** Syncs the content item to it's tree node - this occurs on first load and after saving */ @@ -130,35 +187,8 @@ } else { - $scope.page.loading = true; + getNode(); - //we are editing so get the content item from the server - $scope.getMethod()($scope.contentId) - .then(function (data) { - - $scope.content = data; - - if (data.isChildOfListView && data.trashed === false) { - $scope.page.listViewPath = ($routeParams.page) ? - "/content/content/edit/" + data.parentId + "?page=" + $routeParams.page : - "/content/content/edit/" + data.parentId; - } - - init($scope.content); - - //in one particular special case, after we've created a new item we redirect back to the edit - // route but there might be server validation errors in the collection which we need to display - // after the redirect, so we will bind all subscriptions which will show the server validation errors - // if there are any and then clear them so the collection no longer persists them. - serverValidationManager.executeAndClearAllSubscriptions(); - - syncTreeNode($scope.content, data.path, true); - - resetLastListPageNumber($scope.content); - - $scope.page.loading = false; - - }); } @@ -185,6 +215,8 @@ $scope.page.buttonGroupState = "success"; + }, function(err) { + $scope.page.buttonGroupState = 'error'; }); } @@ -208,9 +240,9 @@ if (!$scope.busy) { // Chromes popup blocker will kick in if a window is opened - // outwith the initial scoped request. This trick will fix that. + // without the initial scoped request. This trick will fix that. // - var previewWindow = $window.open('preview/?id=' + content.id, 'umbpreview'); + var previewWindow = $window.open('preview/?init=true&id=' + content.id, 'umbpreview'); // Build the correct path so both /#/ and #/ work. var redirect = Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath + '/preview/?id=' + content.id; @@ -230,6 +262,87 @@ }; + $scope.restore = function (content) { + + $scope.page.buttonRestore = "busy"; + + relationResource.getByChildId(content.id, "relateParentDocumentOnDelete").then(function (data) { + + var relation = null; + var target = null; + var error = { headline: "Cannot automatically restore this item", content: "Use the Move menu item to move it manually"}; + + if (data.length == 0) { + notificationsService.error(error.headline, "There is no 'restore' relation found for this node. Use the Move menu item to move it manually."); + $scope.page.buttonRestore = "error"; + return; + } + + relation = data[0]; + + if (relation.parentId == -1) { + target = { id: -1, name: "Root" }; + moveNode(content, target); + } else { + contentResource.getById(relation.parentId).then(function (data) { + target = data; + + // make sure the target item isn't in the recycle bin + if(target.path.indexOf("-20") !== -1) { + notificationsService.error(error.headline, "The item you want to restore it under (" + target.name + ") is in the recycle bin. Use the Move menu item to move the item manually."); + $scope.page.buttonRestore = "error"; + return; + } + + moveNode(content, target); + + }, function (err) { + $scope.page.buttonRestore = "error"; + notificationsService.error(error.headline, error.content); + }); + } + + }, function (err) { + $scope.page.buttonRestore = "error"; + notificationsService.error(error.headline, error.content); + }); + + + }; + + function moveNode(node, target) { + + contentResource.move({ "parentId": target.id, "id": node.id }) + .then(function (path) { + + // remove the node that we're working on + if($scope.page.menu.currentNode) { + treeService.removeNode($scope.page.menu.currentNode); + } + + // sync the destination node + navigationService.syncTree({ tree: "content", path: path, forceReload: true, activate: false }); + + $scope.page.buttonRestore = "success"; + notificationsService.success("Successfully restored " + node.name + " to " + target.name); + + // reload the node + getNode(); + + }, function (err) { + $scope.page.buttonRestore = "error"; + notificationsService.error("Cannot automatically restore this item", err); + }); + + } + + //ensure to unregister from all events! + $scope.$on('$destroy', function () { + for (var e in evts) { + eventsService.unsubscribe(evts[e]); + } + }); + } function createDirective() { diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbcontentnodeinfo.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbcontentnodeinfo.directive.js new file mode 100644 index 0000000000..2ce0c5a22e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbcontentnodeinfo.directive.js @@ -0,0 +1,293 @@ +(function () { + 'use strict'; + + function ContentNodeInfoDirective($timeout, $location, logResource, eventsService, userService, localizationService, dateHelper) { + + function link(scope, element, attrs, ctrl) { + + var evts = []; + var isInfoTab = false; + scope.publishStatus = {}; + + function onInit() { + + scope.allowOpen = true; + + scope.datePickerConfig = { + pickDate: true, + pickTime: true, + useSeconds: false, + format: "YYYY-MM-DD HH:mm", + icons: { + time: "icon-time", + date: "icon-calendar", + up: "icon-chevron-up", + down: "icon-chevron-down" + } + }; + + scope.auditTrailOptions = { + "id": scope.node.id + }; + + // get available templates + scope.availableTemplates = scope.node.allowedTemplates; + + // get document type details + scope.documentType = scope.node.documentType; + + // make sure dates are formatted to the user's locale + formatDatesToLocal(); + + setNodePublishStatus(scope.node); + + } + + scope.auditTrailPageChange = function (pageNumber) { + scope.auditTrailOptions.pageNumber = pageNumber; + loadAuditTrail(); + }; + + scope.openDocumentType = function (documentType) { + var url = "/settings/documenttypes/edit/" + documentType.id; + $location.url(url); + }; + + scope.updateTemplate = function (templateAlias) { + + // update template value + scope.node.template = templateAlias; + + }; + + scope.datePickerChange = function (event, type) { + if (type === 'publish') { + setPublishDate(event.date.format("YYYY-MM-DD HH:mm")); + } else if (type === 'unpublish') { + setUnpublishDate(event.date.format("YYYY-MM-DD HH:mm")); + } + }; + + scope.clearPublishDate = function () { + clearPublishDate(); + }; + + scope.clearUnpublishDate = function () { + clearUnpublishDate(); + }; + + function loadAuditTrail() { + + scope.loadingAuditTrail = true; + + logResource.getPagedEntityLog(scope.auditTrailOptions) + .then(function (data) { + + // get current backoffice user and format dates + userService.getCurrentUser().then(function (currentUser) { + angular.forEach(data.items, function(item) { + item.timestampFormatted = dateHelper.getLocalDate(item.timestamp, currentUser.locale, 'LLL'); + }); + }); + + scope.auditTrail = data.items; + scope.auditTrailOptions.pageNumber = data.pageNumber; + scope.auditTrailOptions.pageSize = data.pageSize; + scope.auditTrailOptions.totalItems = data.totalItems; + scope.auditTrailOptions.totalPages = data.totalPages; + + setAuditTrailLogTypeColor(scope.auditTrail); + + scope.loadingAuditTrail = false; + }); + + } + + function setAuditTrailLogTypeColor(auditTrail) { + angular.forEach(auditTrail, function (item) { + switch (item.logType) { + case "Publish": + item.logTypeColor = "success"; + break; + case "UnPublish": + case "Delete": + item.logTypeColor = "danger"; + break; + default: + item.logTypeColor = "gray"; + } + }); + } + + function setNodePublishStatus(node) { + + // deleted node + if(node.trashed === true) { + scope.publishStatus.label = localizationService.localize("general_deleted"); + scope.publishStatus.color = "danger"; + } + + // unpublished node + if(node.published === false && node.trashed === false) { + scope.publishStatus.label = localizationService.localize("content_unpublished"); + scope.publishStatus.color = "gray"; + } + + // published node + if(node.hasPublishedVersion === true && node.publishDate && node.published === true) { + scope.publishStatus.label = localizationService.localize("content_published"); + scope.publishStatus.color = "success"; + } + + // published node with pending changes + if(node.hasPublishedVersion === true && node.publishDate && node.published === false) { + scope.publishStatus.label = localizationService.localize("content_publishedPendingChanges"); + scope.publishStatus.color = "success" + } + + } + + function setPublishDate(date) { + + if (!date) { + return; + } + + //The date being passed in here is the user's local date/time that they have selected + //we need to convert this date back to the server date on the model. + + var serverTime = dateHelper.convertToServerStringTime(moment(date), Umbraco.Sys.ServerVariables.application.serverTimeOffset); + + // update publish value + scope.node.releaseDate = serverTime; + + // make sure dates are formatted to the user's locale + formatDatesToLocal(); + + // emit event + var args = { node: scope.node, date: date }; + eventsService.emit("editors.content.changePublishDate", args); + + } + + function clearPublishDate() { + + // update publish value + scope.node.releaseDate = null; + + // emit event + var args = { node: scope.node, date: null }; + eventsService.emit("editors.content.changePublishDate", args); + + } + + function setUnpublishDate(date) { + + if (!date) { + return; + } + + //The date being passed in here is the user's local date/time that they have selected + //we need to convert this date back to the server date on the model. + + var serverTime = dateHelper.convertToServerStringTime(moment(date), Umbraco.Sys.ServerVariables.application.serverTimeOffset); + + // update publish value + scope.node.removeDate = serverTime; + + // make sure dates are formatted to the user's locale + formatDatesToLocal(); + + // emit event + var args = { node: scope.node, date: date }; + eventsService.emit("editors.content.changeUnpublishDate", args); + + } + + function clearUnpublishDate() { + + // update publish value + scope.node.removeDate = null; + + // emit event + var args = { node: scope.node, date: null }; + eventsService.emit("editors.content.changeUnpublishDate", args); + + } + + function ucfirst(string) { + return string.charAt(0).toUpperCase() + string.slice(1); + } + + function formatDatesToLocal() { + // get current backoffice user and format dates + userService.getCurrentUser().then(function (currentUser) { + scope.node.createDateFormatted = dateHelper.getLocalDate(scope.node.createDate, currentUser.locale, 'LLL'); + + scope.node.releaseDateYear = scope.node.releaseDate ? ucfirst(dateHelper.getLocalDate(scope.node.releaseDate, currentUser.locale, 'YYYY')) : null; + scope.node.releaseDateMonth = scope.node.releaseDate ? ucfirst(dateHelper.getLocalDate(scope.node.releaseDate, currentUser.locale, 'MMMM')) : null; + scope.node.releaseDateDayNumber = scope.node.releaseDate ? ucfirst(dateHelper.getLocalDate(scope.node.releaseDate, currentUser.locale, 'DD')) : null; + scope.node.releaseDateDay = scope.node.releaseDate ? ucfirst(dateHelper.getLocalDate(scope.node.releaseDate, currentUser.locale, 'dddd')) : null; + scope.node.releaseDateTime = scope.node.releaseDate ? ucfirst(dateHelper.getLocalDate(scope.node.releaseDate, currentUser.locale, 'HH:mm')) : null; + + scope.node.removeDateYear = scope.node.removeDate ? ucfirst(dateHelper.getLocalDate(scope.node.removeDate, currentUser.locale, 'YYYY')) : null; + scope.node.removeDateMonth = scope.node.removeDate ? ucfirst(dateHelper.getLocalDate(scope.node.removeDate, currentUser.locale, 'MMMM')) : null; + scope.node.removeDateDayNumber = scope.node.removeDate ? ucfirst(dateHelper.getLocalDate(scope.node.removeDate, currentUser.locale, 'DD')) : null; + scope.node.removeDateDay = scope.node.removeDate ? ucfirst(dateHelper.getLocalDate(scope.node.removeDate, currentUser.locale, 'dddd')) : null; + scope.node.removeDateTime = scope.node.removeDate ? ucfirst(dateHelper.getLocalDate(scope.node.removeDate, currentUser.locale, 'HH:mm')) : null; + }); + } + + // load audit trail when on the info tab + evts.push(eventsService.on("app.tabChange", function (event, args) { + $timeout(function(){ + if (args.id === -1) { + isInfoTab = true; + loadAuditTrail(); + } else { + isInfoTab = false; + } + }); + })); + + // watch for content updates - reload content when node is saved, published etc. + scope.$watch('node.updateDate', function(newValue, oldValue){ + + if(!newValue) { return; } + if(newValue === oldValue) { return; } + + if(isInfoTab) { + loadAuditTrail(); + formatDatesToLocal(); + setNodePublishStatus(scope.node); + } + }); + + //ensure to unregister from all events! + scope.$on('$destroy', function () { + for (var e in evts) { + eventsService.unsubscribe(evts[e]); + } + }); + + onInit(); + + } + + var directive = { + restrict: 'E', + replace: true, + templateUrl: 'views/components/content/umb-content-node-info.html', + scope: { + node: "=" + }, + link: link + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbContentNodeInfo', ContentNodeInfoDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/events/events.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/events/events.directive.js index de497ccffe..6f4111373d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/events/events.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/events/events.directive.js @@ -156,7 +156,7 @@ angular.module('umbraco.directives') if(els.indexOf(el) >= 0){return;} // ignore clicks on new overlay - var parents = $(event.target).parents("a,button,.umb-overlay"); + var parents = $(event.target).parents("a,button,.umb-overlay,.umb-tour"); if(parents.length > 0){ return; } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js index e3a50b482c..90daac73db 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js @@ -115,7 +115,9 @@ angular.module("umbraco.directives") toolbar: toolbar, content_css: stylesheets, style_formats: styleFormats, - autoresize_bottom_margin: 0 + autoresize_bottom_margin: 0, + //see http://archive.tinymce.com/wiki.php/Configuration:cache_suffix + cache_suffix: "?umb__rnd=" + Umbraco.Sys.ServerVariables.application.cacheBuster }; @@ -366,6 +368,9 @@ angular.module("umbraco.directives") // element might still be there even after the modal has been hidden. scope.$on('$destroy', function () { unsubscribe(); + if (tinyMceEditor !== undefined && tinyMceEditor != null) { + tinyMceEditor.destroy() + } }); }); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/media/umbmedianodeinfo.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/media/umbmedianodeinfo.directive.js new file mode 100644 index 0000000000..1aa026cb3a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/media/umbmedianodeinfo.directive.js @@ -0,0 +1,67 @@ +(function () { + 'use strict'; + + function MediaNodeInfoDirective($timeout, $location, eventsService, userService, dateHelper) { + + function link(scope, element, attrs, ctrl) { + + var evts = []; + + function onInit() { + scope.allowOpenMediaType = true; + // get document type details + scope.mediaType = scope.node.contentType; + // get node url + scope.nodeUrl = scope.node.mediaLink; + // make sure dates are formatted to the user's locale + formatDatesToLocal(); + } + + function formatDatesToLocal() { + // get current backoffice user and format dates + userService.getCurrentUser().then(function (currentUser) { + scope.node.createDateFormatted = dateHelper.getLocalDate(scope.node.createDate, currentUser.locale, 'LLL'); + scope.node.updateDateFormatted = dateHelper.getLocalDate(scope.node.updateDate, currentUser.locale, 'LLL'); + }); + } + + scope.openMediaType = function (mediaType) { + // remove first "#" from url if it is prefixed else the path won't work + var url = "/settings/mediaTypes/edit/" + mediaType.id; + $location.path(url); + }; + + // watch for content updates - reload content when node is saved, published etc. + scope.$watch('node.updateDate', function(newValue, oldValue){ + if(!newValue) { return; } + if(newValue === oldValue) { return; } + formatDatesToLocal(); + }); + + //ensure to unregister from all events! + scope.$on('$destroy', function () { + for (var e in evts) { + eventsService.unsubscribe(evts[e]); + } + }); + + onInit(); + + } + + var directive = { + restrict: 'E', + replace: true, + templateUrl: 'views/components/media/umb-media-node-info.html', + scope: { + node: "=" + }, + link: link + }; + + return directive; + } + + angular.module('umbraco.directives').directive('umbMediaNodeInfo', MediaNodeInfoDirective); + +})(); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js index d728865015..6e705da52d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js @@ -59,7 +59,6 @@

General Options

-Lorem ipsum dolor sit amet.. @@ -74,7 +73,7 @@ Lorem ipsum dolor sit amet.. - + diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js index adbc3a96a7..69457a6f10 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js @@ -4,29 +4,32 @@ * @restrict E **/ angular.module("umbraco.directives") - .directive('umbProperty', function (umbPropEditorHelper) { + .directive('umbProperty', function (umbPropEditorHelper, userService) { return { scope: { property: "=" }, transclude: true, restrict: 'E', - replace: true, + replace: true, templateUrl: 'views/components/property/umb-property.html', - link: function(scope) { - scope.propertyAlias = Umbraco.Sys.ServerVariables.isDebuggingEnabled === true ? scope.property.alias : null; + link: function (scope) { + userService.getCurrentUser().then(function (u) { + var isAdmin = u.userGroups.indexOf('admin') !== -1; + scope.propertyAlias = (Umbraco.Sys.ServerVariables.isDebuggingEnabled === true || isAdmin) ? scope.property.alias : null; + }); }, //Define a controller for this directive to expose APIs to other directives controller: function ($scope, $timeout) { - + var self = this; //set the API properties/methods - + self.property = $scope.property; - self.setPropertyError = function(errorMsg) { + self.setPropertyError = function (errorMsg) { $scope.property.propertyErrorMessage = errorMsg; }; } }; - }); \ No newline at end of file + }); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/tabs/umbtabs.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/tabs/umbtabs.directive.js index aa23b80665..494c4a3f7d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/tabs/umbtabs.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/tabs/umbtabs.directive.js @@ -8,7 +8,7 @@ angular.module("umbraco.directives") .directive('umbTabs', function () { return { restrict: 'A', - controller: function ($scope, $element, $attrs) { + controller: function ($scope, $element, $attrs, eventsService) { var callbacks = []; this.onTabShown = function(cb) { @@ -19,14 +19,21 @@ angular.module("umbraco.directives") var curr = $(event.target); // active tab var prev = $(event.relatedTarget); // previous tab + + // emit tab change event + var tabId = Number(curr.context.hash.replace("#tab", "")); + var args = { id: tabId, hash: curr.context.hash }; + + eventsService.emit("app.tabChange", args); $scope.$apply(); for (var c in callbacks) { callbacks[c].apply(this, [{current: curr, previous: prev}]); } + } - + //NOTE: it MUST be done this way - binding to an ancestor element that exists // in the DOM to bind to the dynamic elements that will be created. // It would be nicer to create this event handler as a directive for which child diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtree.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtree.directive.js index 6ed1a21306..cce4e0d792 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtree.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtree.directive.js @@ -31,10 +31,10 @@ function umbTreeDirective($compile, $log, $q, $rootScope, treeService, notificat //var showheader = (attrs.showheader !== 'false'); var hideoptions = (attrs.hideoptions === 'true') ? "hide-options" : ""; var template = '
  • '; - template += '
    ' + + template += '
    ' + '
    ' + ' {{tree.name}}
    ' + - '' + + '' + '
    '; template += '
      ' + '' + @@ -148,13 +148,13 @@ function umbTreeDirective($compile, $log, $q, $rootScope, treeService, notificat userService.getCurrentUser().then(function (userData) { - var startNodes = []; + var startNodes = []; for (var i = 0; i < userData.startContentIds; i++) { startNodes.push(userData.startContentIds[i]); } for (var j = 0; j < userData.startMediaIds; j++) { startNodes.push(userData.startMediaIds[j]); - } + } _.each(startNodes, function (i) { var found = _.find(args.path, function (p) { @@ -317,17 +317,17 @@ function umbTreeDirective($compile, $log, $q, $rootScope, treeService, notificat } //TODO: This is called constantly because as a method in a template it's re-evaluated pretty much all the time - // it would be better if we could cache the processing. The problem is that some of these things are dynamic. + // it would be better if we could cache the processing. The problem is that some of these things are dynamic. var css = []; if (node.cssClasses) { _.each(node.cssClasses, function (c) { css.push(c); }); - } + } return css.join(" "); - }; + }; scope.selectEnabledNodeClass = function (node) { return node ? @@ -406,7 +406,7 @@ function umbTreeDirective($compile, $log, $q, $rootScope, treeService, notificat if (n.metaData && n.metaData.noAccess === true) { ev.preventDefault(); return; - } + } //on tree select we need to remove the current node - // whoever handles this will need to make sure the correct node is selected diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtreeitem.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtreeitem.directive.js index 25c1becc87..0bb888a59f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtreeitem.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/tree/umbtreeitem.directive.js @@ -35,15 +35,15 @@ angular.module("umbraco.directives") //TODO: Remove more of the binding from this template and move the DOM manipulation to be manually done in the link function, // this will greatly improve performance since there's potentially a lot of nodes being rendered = a LOT of watches! - template: '
    • ' + - '
      ' + + template: '
    • ' + + '
      ' + //NOTE: This ins element is used to display the search icon if the node is a container/listview and the tree is currently in dialog //'' + - ' ' + + ' ' + '' + '' + //NOTE: These are the 'option' elipses - '' + + '' + '
      ' + '
      ' + '
    • ', @@ -96,6 +96,14 @@ angular.module("umbraco.directives") if (node.style) { element.find("i:first").attr("style", node.style); } + + // add a unique data element to each tree item so it is easy to navigate with code + if(!node.metaData.treeAlias) { + node.dataElement = node.name; + } else { + node.dataElement = node.metaData.treeAlias; + } + } //This will deleteAnimations to true after the current digest diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbavatar.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbavatar.directive.js index 7fad3e8a74..7dd2f0d7a3 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbavatar.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbavatar.directive.js @@ -66,9 +66,13 @@ Use this directive to render an avatar. function getNameInitials(name) { if(name) { - var initials = name.match(/\b\w/g) || []; - initials = ((initials.shift() || '') + (initials.pop() || '')).toUpperCase(); - return initials; + var names = name.split(' '), + initials = names[0].substring(0, 1); + + if (names.length > 1) { + initials += names[names.length - 1].substring(0, 1); + } + return initials.toUpperCase(); } return null; } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbdatetimepicker.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbdatetimepicker.directive.js index d7e44df113..7047fdb0a2 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbdatetimepicker.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbdatetimepicker.directive.js @@ -84,7 +84,13 @@ Use this directive to render a date time picker function link(scope, element, attrs, ctrl) { + scope.hasTranscludedContent = false; + function onInit() { + + // check for transcluded content so we can hide the defualt markup + scope.hasTranscludedContent = element.find('.js-datePicker__transcluded-content')[0].children.length > 0; + // load css file for the date picker assetsService.loadCss('lib/datetimepicker/bootstrap-datetimepicker.min.css'); @@ -148,7 +154,7 @@ Use this directive to render a date time picker .on("dp.show", onShow) .on("dp.change", onChange) .on("dp.error", onError) - .on("dp.update", onUpdate); + .on("dp.update", onUpdate); } onInit(); @@ -158,6 +164,7 @@ Use this directive to render a date time picker var directive = { restrict: 'E', replace: true, + transclude: true, templateUrl: 'views/components/umb-date-time-picker.html', scope: { options: "=", diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbgroupsbuilder.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbgroupsbuilder.directive.js index caa79439be..c0be05addf 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbgroupsbuilder.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbgroupsbuilder.directive.js @@ -489,7 +489,7 @@ scope.editPropertyTypeSettings = function(property, group) { - if (!property.inherited && !property.locked) { + if (!property.inherited) { scope.propertySettingsDialogModel = {}; scope.propertySettingsDialogModel.title = "Property settings"; @@ -547,6 +547,7 @@ property.validation.pattern = oldModel.property.validation.pattern; property.showOnMemberProfile = oldModel.property.showOnMemberProfile; property.memberCanEdit = oldModel.property.memberCanEdit; + property.isSensitiveValue = oldModel.property.isSensitiveValue; // because we set state to active, to show a preview, we have to check if has been filled out // label is required so if it is not filled we know it is a placeholder diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbmediagrid.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbmediagrid.directive.js index c0bd7a4eff..12179076cd 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbmediagrid.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbmediagrid.directive.js @@ -110,7 +110,7 @@ Use this directive to generate a thumbnail grid of media items. itemMinWidth = scope.itemMinWidth; } - if (scope.itemMinWidth) { + if (scope.itemMinHeight) { itemMinHeight = scope.itemMinHeight; } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbnodepreview.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbnodepreview.directive.js index d98246ac42..556019857b 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbnodepreview.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbnodepreview.directive.js @@ -77,7 +77,7 @@ @param {string} icon (binding): The node icon. @param {string} name (binding): The node name. -@param {boolean} published (binding): The node pusblished state. +@param {boolean} published (binding): The node published state. @param {string} description (binding): A short description. @param {boolean} sortable (binding): Will add a move cursor on the node preview. Can used in combination with ui-sortable. @param {boolean} allowRemove (binding): Show/Hide the remove button. diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbpasswordtoggle.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbpasswordtoggle.directive.js new file mode 100644 index 0000000000..aac1b8dac1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbpasswordtoggle.directive.js @@ -0,0 +1,37 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbPasswordToggle +@restrict E +@scope + +@description +Added in Umbraco v. 7.7.4: Use this directive to render a password toggle. + +**/ + +(function () { + 'use strict'; + + // comes from https://codepen.io/jakob-e/pen/eNBQaP + // works fine with Angular 1.6.5 - alas not with 1.1.5 - binding issue + + function PasswordToggleDirective($compile) { + + var directive = { + restrict: 'A', + scope: {}, + link: function(scope, elem, attrs) { + scope.tgl = function () { elem.attr("type", (elem.attr("type") === "text" ? "password" : "text")); } + var lnk = angular.element("Toggle"); + $compile(lnk)(scope); + elem.wrap("
      ").after(lnk); + } + }; + + return directive; + + } + + angular.module('umbraco.directives').directive('umbPasswordToggle', PasswordToggleDirective); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbprogresscircle.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbprogresscircle.directive.js new file mode 100644 index 0000000000..ad79cb2e3b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbprogresscircle.directive.js @@ -0,0 +1,88 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbProgressCircle +@restrict E +@scope + +@description +Use this directive to render a circular progressbar. + +

      Markup example

      +
      +    
      + + + + +
      +
      + +@param {string} size (attribute): This parameter defines the width and the height of the circle in pixels. +@param {string} percentage (attribute): Takes a number between 0 and 100 and applies it to the circle's highlight length. +@param {string} color (attribute): the color of the highlight (primary, secondary, success, warning, danger). Success by default. +**/ + +(function (){ + 'use strict'; + + function ProgressCircleDirective($http, $timeout) { + + function link(scope, element, $filter) { + + function onInit() { + + // making sure we get the right numbers + var percent = scope.percentage; + + if (percent > 100) { + percent = 100; + } + else if (percent < 0) { + percent = 0; + } + + // calculating the circle's highlight + var circle = element.find(".umb-progress-circle__highlight"); + var r = circle.attr('r'); + var strokeDashArray = (r*Math.PI)*2; + + // Full circle length + scope.strokeDashArray = strokeDashArray; + + var strokeDashOffsetDifference = (percent/100)*strokeDashArray; + var strokeDashOffset = strokeDashArray - strokeDashOffsetDifference; + + // Distance for the highlight dash's offset + scope.strokeDashOffset = strokeDashOffset; + + // set font size + scope.percentageSize = (scope.size * 0.3) + "px"; + + } + + onInit(); + } + + + var directive = { + restrict: 'E', + replace: true, + templateUrl: 'views/components/umb-progress-circle.html', + scope: { + size: "@?", + percentage: "@", + color: "@" + }, + link: link + + }; + + return directive; + } + + angular.module('umbraco.directives').directive('umbProgressCircle', ProgressCircleDirective); + +})(); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbtable.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbtable.directive.js index 17d4dd93ff..c45a9f78e5 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbtable.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbtable.directive.js @@ -1,3 +1,114 @@ +/** +@ngdoc directive +@name umbraco.directives.directive:umbTable +@restrict E +@scope + +@description +Added in Umbraco v. 7.4: Use this directive to render a data table. + +

      Markup example

      +
      +    
      + + + + +
      +
      + +

      Controller example

      +
      +    (function () {
      +        "use strict";
      +    
      +        function Controller() {
      +    
      +            var vm = this;
      +    
      +            vm.items = [
      +                {
      +                    "icon": "icon-document",
      +                    "name": "My node 1",
      +                    "published": true,
      +                    "description": "A short description of my node",
      +                    "author": "Author 1"
      +                },
      +                {
      +                    "icon": "icon-document",
      +                    "name": "My node 2",
      +                    "published": true,
      +                    "description": "A short description of my node",
      +                    "author": "Author 2"
      +                }
      +            ];
      +
      +            vm.options = {
      +                includeProperties: [
      +                    { alias: "description", header: "Description" },
      +                    { alias: "author", header: "Author" }
      +                ]
      +            };
      +    
      +            vm.selectItem = selectItem;
      +            vm.clickItem = clickItem;
      +            vm.selectAll = selectAll;
      +            vm.isSelectedAll = isSelectedAll;
      +            vm.isSortDirection = isSortDirection;
      +            vm.sort = sort;
      +
      +            function selectAll($event) {
      +                alert("select all");
      +            }
      +
      +            function isSelectedAll() {
      +                
      +            }
      +    
      +            function clickItem(item) {
      +                alert("click node");
      +            }
      +
      +            function selectItem(selectedItem, $index, $event) {
      +                alert("select node");
      +            }
      +            
      +            function isSortDirection(col, direction) {
      +                
      +            }
      +            
      +            function sort(field, allow, isSystem) {
      +                
      +            }
      +    
      +        }
      +    
      +        angular.module("umbraco").controller("My.TableController", Controller);
      +    
      +    })();
      +
      + +@param {string} icon (binding): The node icon. +@param {string} name (binding): The node name. +@param {string} published (binding): The node published state. +@param {function} onSelect (expression): Callback function when the row is selected. +@param {function} onClick (expression): Callback function when the "Name" column link is clicked. +@param {function} onSelectAll (expression): Callback function when selecting all items. +@param {function} onSelectedAll (expression): Callback function when all items are selected. +@param {function} onSortingDirection (expression): Callback function when sorting direction is changed. +@param {function} onSort (expression): Callback function when sorting items. +**/ + (function () { 'use strict'; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/validation/nodirtycheck.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/validation/nodirtycheck.directive.js index f027d7a12f..104736530f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/validation/nodirtycheck.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/validation/nodirtycheck.directive.js @@ -9,9 +9,14 @@ function noDirtyCheck() { restrict: 'A', require: 'ngModel', link: function (scope, elm, attrs, ctrl) { - elm.focus(function () { - ctrl.$pristine = false; - }); + + var alwaysFalse = { + get: function () { return false; }, + set: function () { } + }; + Object.defineProperty(ctrl, '$pristine', alwaysFalse); + Object.defineProperty(ctrl, '$dirty', alwaysFalse); + } }; } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/validation/valemail.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/validation/valemail.directive.js index 8574d01f5a..273ab07593 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/validation/valemail.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/validation/valemail.directive.js @@ -47,8 +47,8 @@ function valEmail(valEmailExpression) { angular.module('umbraco.directives.validation') .directive("valEmail", valEmail) .factory('valEmailExpression', function () { - //NOTE: This is the fixed regex which is part of the newer angular + var emailRegex = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; return { - EMAIL_REGEXP: /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i + EMAIL_REGEXP: emailRegex }; }); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/currentuser.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/currentuser.resource.js index 233d510c3e..0a6b3cd6bc 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/currentuser.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/currentuser.resource.js @@ -10,6 +10,30 @@ function currentUserResource($q, $http, umbRequestHelper, umbDataFormatter) { //the factory object returned return { + saveTourStatus: function (tourStatus) { + + if (!tourStatus) { + return angularHelper.rejectedPromise({ errorMsg: 'tourStatus cannot be empty' }); + } + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "currentUserApiBaseUrl", + "PostSetUserTour"), + tourStatus), + 'Failed to save tour status'); + }, + + getTours: function () { + + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "currentUserApiBaseUrl", + "GetUserTours")), 'Failed to get tours'); + }, + performSetInvitedUserPassword: function (newPassword) { if (!newPassword) { diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js index f5e2748360..5dd353d9e0 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js @@ -41,7 +41,7 @@ function entityResource($q, $http, umbRequestHelper) { if (!value) { return ""; } - + value = value.replace("#", ""); return umbRequestHelper.resourcePromise( $http.get( umbRequestHelper.getApiUrl( diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/log.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/log.resource.js index a5e8149952..cb676511a5 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/log.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/log.resource.js @@ -9,7 +9,73 @@ function logResource($q, $http, umbRequestHelper) { //the factory object returned return { - + + getPagedEntityLog: function (options) { + + var defaults = { + pageSize: 10, + pageNumber: 1, + orderDirection: "Descending" + }; + if (options === undefined) { + options = {}; + } + //overwrite the defaults if there are any specified + angular.extend(defaults, options); + //now copy back to the options we will use + options = defaults; + //change asc/desct + if (options.orderDirection === "asc") { + options.orderDirection = "Ascending"; + } + else if (options.orderDirection === "desc") { + options.orderDirection = "Descending"; + } + + if (options.id === undefined || options.id === null) { + throw "options.id is required"; + } + + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "logApiBaseUrl", + "GetPagedEntityLog", + options)), + 'Failed to retrieve log data for id'); + }, + + getPagedUserLog: function (options) { + + var defaults = { + pageSize: 10, + pageNumber: 1, + orderDirection: "Descending" + }; + if (options === undefined) { + options = {}; + } + //overwrite the defaults if there are any specified + angular.extend(defaults, options); + //now copy back to the options we will use + options = defaults; + //change asc/desct + if (options.orderDirection === "asc") { + options.orderDirection = "Ascending"; + } + else if (options.orderDirection === "desc") { + options.orderDirection = "Descending"; + } + + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "logApiBaseUrl", + "GetPagedEntityLog", + options)), + 'Failed to retrieve log data for id'); + }, + /** * @ngdoc method * @name umbraco.resources.logResource#getEntityLog diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/media.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/media.resource.js index d1810f56ee..93a92db1ec 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/media.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/media.resource.js @@ -493,7 +493,7 @@ function mediaResource($q, $http, umbDataFormatter, umbRequestHelper) { * @methodOf umbraco.resources.mediaResource * * @description - * Empties the media recycle bin + * Paginated search for media items starting on the supplied nodeId * * ##usage *
      @@ -506,7 +506,7 @@ function mediaResource($q, $http, umbDataFormatter, umbRequestHelper) {
                 * @param {string} query The search query
                 * @param {int} pageNumber The page number
                 * @param {int} pageSize The number of media items on a page
      -          * @param {int} searchFrom Id to search from
      +          * @param {int} searchFrom NodeId to search from (-1 for root)
                 * @returns {Promise} resourcePromise object.
                 *
                 */
      diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/member.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/member.resource.js
      index 2073307db9..353fe12cb8 100644
      --- a/src/Umbraco.Web.UI.Client/src/common/resources/member.resource.js
      +++ b/src/Umbraco.Web.UI.Client/src/common/resources/member.resource.js
      @@ -10,8 +10,8 @@ function memberResource($q, $http, umbDataFormatter, umbRequestHelper) {
       
               return umbRequestHelper.postSaveContent({
                   restApiUrl: umbRequestHelper.getApiUrl(
      -                   "memberApiBaseUrl",
      -                   "PostSave"),
      +                "memberApiBaseUrl",
      +                "PostSave"),
                   content: content,
                   action: action,
                   files: files,
      @@ -22,8 +22,7 @@ function memberResource($q, $http, umbDataFormatter, umbRequestHelper) {
           }
       
           return {
      -
      -        getPagedResults: function (memberTypeAlias, options) {
      +        getPagedResults: function(memberTypeAlias, options) {
       
                   if (memberTypeAlias === 'all-members') {
                       memberTypeAlias = null;
      @@ -67,35 +66,35 @@ function memberResource($q, $http, umbDataFormatter, umbRequestHelper) {
                   }
       
                   var params = [
      -                   { pageNumber: options.pageNumber },
      -                   { pageSize: options.pageSize },
      -                   { orderBy: options.orderBy },
      -                   { orderDirection: options.orderDirection },
      -                   { orderBySystemField: toBool(options.orderBySystemField) },
      -                   { filter: options.filter }
      +                { pageNumber: options.pageNumber },
      +                { pageSize: options.pageSize },
      +                { orderBy: options.orderBy },
      +                { orderDirection: options.orderDirection },
      +                { orderBySystemField: toBool(options.orderBySystemField) },
      +                { filter: options.filter }
                   ];
                   if (memberTypeAlias != null) {
                       params.push({ memberTypeAlias: memberTypeAlias });
                   }
       
                   return umbRequestHelper.resourcePromise(
      -                  $http.get(
      -                        umbRequestHelper.getApiUrl(
      -                              "memberApiBaseUrl",
      -                              "GetPagedResults",
      -                              params)),
      -                  'Failed to retrieve member paged result');
      +                $http.get(
      +                    umbRequestHelper.getApiUrl(
      +                        "memberApiBaseUrl",
      +                        "GetPagedResults",
      +                        params)),
      +                'Failed to retrieve member paged result');
               },
       
      -        getListNode: function (listName) {
      +        getListNode: function(listName) {
       
                   return umbRequestHelper.resourcePromise(
      -                  $http.get(
      -                        umbRequestHelper.getApiUrl(
      -                              "memberApiBaseUrl",
      -                              "GetListNodeDisplay",
      -                              [{ listName: listName }])),
      -                  'Failed to retrieve data for member list ' + listName);
      +                $http.get(
      +                    umbRequestHelper.getApiUrl(
      +                        "memberApiBaseUrl",
      +                        "GetListNodeDisplay",
      +                        [{ listName: listName }])),
      +                'Failed to retrieve data for member list ' + listName);
               },
       
               /**
      @@ -119,15 +118,15 @@ function memberResource($q, $http, umbDataFormatter, umbRequestHelper) {
                 * @returns {Promise} resourcePromise object containing the member item.
                 *
                 */
      -        getByKey: function (key) {
      +        getByKey: function(key) {
       
                   return umbRequestHelper.resourcePromise(
      -                  $http.get(
      -                        umbRequestHelper.getApiUrl(
      -                              "memberApiBaseUrl",
      -                              "GetByKey",
      -                              [{ key: key }])),
      -                  'Failed to retrieve data for member id ' + key);
      +                $http.get(
      +                    umbRequestHelper.getApiUrl(
      +                        "memberApiBaseUrl",
      +                        "GetByKey",
      +                        [{ key: key }])),
      +                'Failed to retrieve data for member id ' + key);
               },
       
               /**
      @@ -150,14 +149,14 @@ function memberResource($q, $http, umbDataFormatter, umbRequestHelper) {
                 * @returns {Promise} resourcePromise object.
                 *
                 */
      -        deleteByKey: function (key) {
      +        deleteByKey: function(key) {
                   return umbRequestHelper.resourcePromise(
      -                   $http.post(
      -                         umbRequestHelper.getApiUrl(
      -                               "memberApiBaseUrl",
      -                               "DeleteByKey",
      -                               [{ key: key }])),
      -                   'Failed to delete item ' + key);
      +                $http.post(
      +                    umbRequestHelper.getApiUrl(
      +                        "memberApiBaseUrl",
      +                        "DeleteByKey",
      +                        [{ key: key }])),
      +                'Failed to delete item ' + key);
               },
       
               /**
      @@ -190,24 +189,24 @@ function memberResource($q, $http, umbDataFormatter, umbRequestHelper) {
                 * @returns {Promise} resourcePromise object containing the member scaffold.
                 *
                 */
      -        getScaffold: function (alias) {
      +        getScaffold: function(alias) {
       
                   if (alias) {
                       return umbRequestHelper.resourcePromise(
      -                        $http.get(
      -                              umbRequestHelper.getApiUrl(
      -                                    "memberApiBaseUrl",
      -                                    "GetEmpty",
      -                                    [{ contentTypeAlias: alias }])),
      -                        'Failed to retrieve data for empty member item type ' + alias);
      +                    $http.get(
      +                        umbRequestHelper.getApiUrl(
      +                            "memberApiBaseUrl",
      +                            "GetEmpty",
      +                            [{ contentTypeAlias: alias }])),
      +                    'Failed to retrieve data for empty member item type ' + alias);
                   }
                   else {
                       return umbRequestHelper.resourcePromise(
      -                        $http.get(
      -                              umbRequestHelper.getApiUrl(
      -                                    "memberApiBaseUrl",
      -                                    "GetEmpty")),
      -                        'Failed to retrieve data for empty member item type ' + alias);
      +                    $http.get(
      +                        umbRequestHelper.getApiUrl(
      +                            "memberApiBaseUrl",
      +                            "GetEmpty")),
      +                    'Failed to retrieve data for empty member item type ' + alias);
                   }
       
               },
      @@ -240,7 +239,7 @@ function memberResource($q, $http, umbDataFormatter, umbRequestHelper) {
                 * @returns {Promise} resourcePromise object containing the saved media item.
                 *
                 */
      -        save: function (member, isNew, files) {
      +        save: function(member, isNew, files) {
                   return saveMember(member, "save" + (isNew ? "New" : ""), files);
               }
           };
      diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js
      new file mode 100644
      index 0000000000..40baf0f389
      --- /dev/null
      +++ b/src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js
      @@ -0,0 +1,35 @@
      +/**
      + * @ngdoc service
      + * @name umbraco.resources.usersResource
      + * @function
      + *
      + * @description
      + * Used by the users section to get users and send requests to create, invite, delete, etc. users.
      + */
      +(function () {
      +    'use strict';
      +
      +    function tourResource($http, umbRequestHelper, $q, umbDataFormatter) {
      +
      +        function getTours() {
      +
      +            return umbRequestHelper.resourcePromise(
      +                $http.get(
      +                    umbRequestHelper.getApiUrl(
      +                        "tourApiBaseUrl",
      +                        "GetTours")),
      +                'Failed to get tours');
      +        }
      +        
      +
      +        var resource = {
      +            getTours: getTours
      +        };
      +
      +        return resource;
      +
      +    }
      +
      +    angular.module('umbraco.resources').factory('tourResource', tourResource);
      +
      +})();
      diff --git a/src/Umbraco.Web.UI.Client/src/common/services/appstate.service.js b/src/Umbraco.Web.UI.Client/src/common/services/appstate.service.js
      index e51310f584..2369af54b5 100644
      --- a/src/Umbraco.Web.UI.Client/src/common/services/appstate.service.js
      +++ b/src/Umbraco.Web.UI.Client/src/common/services/appstate.service.js
      @@ -74,6 +74,15 @@ function appState(eventsService) {
               showMenu: null
           };
       
      +    var drawerState = {
      +        //this view to show
      +        view: null,
      +        // bind custom values to the drawer
      +        model: null,
      +        //Whether the drawer is being shown or not
      +        showDrawer: null
      +    };
      +
           /** function to validate and set the state on a state object */
           function setState(stateObj, key, value, stateObjName) {
               if (!_.has(stateObj, key)) {
      @@ -212,6 +221,35 @@ function appState(eventsService) {
                   setState(menuState, key, value, "menuState");
               },
       
      +        /**
      +         * @ngdoc function
      +         * @name umbraco.services.angularHelper#getDrawerState
      +         * @methodOf umbraco.services.appState
      +         * @function
      +         *
      +         * @description
      +         * Returns the current drawer state value by key - we do not return an object here - we do NOT want this
      +         * to be publicly mutable and allow setting arbitrary values
      +         *
      +         */
      +        getDrawerState: function (key) {
      +            return getState(drawerState, key, "drawerState");
      +        },
      +
      +        /**
      +         * @ngdoc function
      +         * @name umbraco.services.angularHelper#setDrawerState
      +         * @methodOf umbraco.services.appState
      +         * @function
      +         *
      +         * @description
      +         * Sets a drawer state value by key
      +         *
      +         */
      +        setDrawerState: function (key, value) {
      +            setState(drawerState, key, value, "drawerState");
      +        }
      +
           };
       }
       angular.module('umbraco.services').factory('appState', appState);
      diff --git a/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js b/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js
      index a84be208ba..16330f5493 100644
      --- a/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js
      +++ b/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js
      @@ -2,12 +2,12 @@
        * @ngdoc service
        * @name umbraco.services.assetsService
        *
      - * @requires $q 
      + * @requires $q
        * @requires angularHelper
      - *  
      + *
        * @description
        * Promise-based utillity service to lazy-load client-side dependencies inside angular controllers.
      - * 
      + *
        * ##usage
        * To use, simply inject the assetsService into any controller that needs it, and make
        * sure the umbraco.services module is accesible - which it should be by default.
      @@ -18,7 +18,7 @@
        *                 //this code executes when the dependencies are done loading
        *          });
        *      });
      - * 
      + * * * You can also load individual files, which gives you greater control over what attibutes are passed to the file, as well as timeout * @@ -38,13 +38,14 @@ * //loadcss cannot determine when the css is done loading, so this will trigger instantly * }); * }); - * + * */ angular.module('umbraco.services') .factory('assetsService', function ($q, $log, angularHelper, umbRequestHelper, $rootScope, $http) { var initAssetsLoaded = false; - var appendRnd = function (url) { + + function appendRnd (url) { //if we don't have a global umbraco obj yet, the app is bootstrapping if (!Umbraco.Sys.ServerVariables.application) { return url; @@ -77,7 +78,8 @@ angular.module('umbraco.services') return this.loadedAssets[path]; } }, - /** + + /** Internal method. This is called when the application is loading and the user is already authenticated, or once the user is authenticated. There's a few assets the need to be loaded for the application to function but these assets require authentication to load. */ @@ -108,10 +110,10 @@ angular.module('umbraco.services') * * @description * Injects a file as a stylesheet into the document head - * + * * @param {String} path path to the css file to load * @param {Scope} scope optional scope to pass into the loader - * @param {Object} keyvalue collection of attributes to pass to the stylesheet element + * @param {Object} keyvalue collection of attributes to pass to the stylesheet element * @param {Number} timeout in milliseconds * @returns {Promise} Promise object which resolves when the file has loaded */ @@ -149,10 +151,10 @@ angular.module('umbraco.services') * * @description * Injects a file as a javascript into the document - * + * * @param {String} path path to the js file to load * @param {Scope} scope optional scope to pass into the loader - * @param {Object} keyvalue collection of attributes to pass to the script element + * @param {Object} keyvalue collection of attributes to pass to the script element * @param {Number} timeout in milliseconds * @returns {Promise} Promise object which resolves when the file has loaded */ @@ -192,8 +194,8 @@ angular.module('umbraco.services') * @methodOf umbraco.services.assetsService * * @description - * Injects a collection of files, this can be ONLY js files - * + * Injects a collection of css and js files + * * * @param {Array} pathArray string array of paths to the files to load * @param {Scope} scope optional scope to pass into the loader @@ -206,61 +208,72 @@ angular.module('umbraco.services') throw "pathArray must be an array"; } + // Check to see if there's anything to load, resolve promise if not var nonEmpty = _.reject(pathArray, function (item) { return item === undefined || item === ""; - }); + }); - - //don't load anything if there's nothing to load - if (nonEmpty.length > 0) { - var promises = []; - var assets = []; - - //compile a list of promises - //blocking - _.each(nonEmpty, function (path) { - - path = convertVirtualPath(path); - - var asset = service._getAssetPromise(path); - //if not previously loaded, add to list of promises - if (asset.state !== "loaded") { - if (asset.state === "new") { - asset.state = "loading"; - assets.push(asset); - } - - //we need to always push to the promises collection to monitor correct - //execution - promises.push(asset.deferred.promise); - } - }); - - - //gives a central monitoring of all assets to load - promise = $q.all(promises); - - _.each(assets, function (asset) { - LazyLoad.js(appendRnd(asset.path), function () { - asset.state = "loaded"; - if (!scope) { - asset.deferred.resolve(true); - } - else { - angularHelper.safeApply(scope, function () { - asset.deferred.resolve(true); - }); - } - }); - }); - } - else { - //return and resolve + if (nonEmpty.length === 0) { var deferred = $q.defer(); promise = deferred.promise; deferred.resolve(true); + return promise; } + //compile a list of promises + //blocking + var promises = []; + var assets = []; + _.each(nonEmpty, function (path) { + path = convertVirtualPath(path); + var asset = service._getAssetPromise(path); + //if not previously loaded, add to list of promises + if (asset.state !== "loaded") { + if (asset.state === "new") { + asset.state = "loading"; + assets.push(asset); + } + + //we need to always push to the promises collection to monitor correct + //execution + promises.push(asset.deferred.promise); + } + }); + + //gives a central monitoring of all assets to load + promise = $q.all(promises); + + // Split into css and js asset arrays, and use LazyLoad on each array + var cssAssets = _.filter(assets, + function (asset) { + return asset.path.match(/(\.css$|\.css\?)/ig); + }); + var jsAssets = _.filter(assets, + function (asset) { + return asset.path.match(/(\.js$|\.js\?)/ig); + }); + + function assetLoaded(asset) { + asset.state = "loaded"; + if (!scope) { + asset.deferred.resolve(true); + return; + } + angularHelper.safeApply(scope, + function () { + asset.deferred.resolve(true); + }); + } + + if (cssAssets.length > 0) { + var cssPaths = _.map(cssAssets, function (asset) { return appendRnd(asset.path) }); + LazyLoad.css(cssPaths, function() { _.each(cssAssets, assetLoaded); }); + } + + if (jsAssets.length > 0) { + var jsPaths = _.map(jsAssets, function (asset) { return appendRnd(asset.path) }); + LazyLoad.js(jsPaths, function () { _.each(jsAssets, assetLoaded); }); + } return promise; } diff --git a/src/Umbraco.Web.UI.Client/src/common/services/backdrop.service.js b/src/Umbraco.Web.UI.Client/src/common/services/backdrop.service.js new file mode 100644 index 0000000000..e463845a1c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/services/backdrop.service.js @@ -0,0 +1,106 @@ +/** + @ngdoc service + * @name umbraco.services.backdropService + * + * @description + * Added in Umbraco 7.8. Application-wide service for handling backdrops. + * + */ + +(function () { + "use strict"; + + function backdropService(eventsService) { + + var args = { + opacity: null, + element: null, + elementPreventClick: false, + disableEventsOnClick: false, + show: false + }; + + /** + * @ngdoc method + * @name umbraco.services.backdropService#open + * @methodOf umbraco.services.backdropService + * + * @description + * Raises an event to open a backdrop + * @param {Object} options The backdrop options + * @param {Number} options.opacity Sets the opacity on the backdrop (default 0.4) + * @param {DomElement} options.element Highlights a DOM-element (HTML-selector) + * @param {Boolean} options.elementPreventClick Adds blocking element on top of highligted area to prevent all clicks + * @param {Boolean} options.disableEventsOnClick Disables all raised events when the backdrop is clicked + */ + function open(options) { + + if(options && options.element) { + args.element = options.element; + } + + if(options && options.disableEventsOnClick) { + args.disableEventsOnClick = options.disableEventsOnClick; + } + + args.show = true; + + eventsService.emit("appState.backdrop", args); + } + + /** + * @ngdoc method + * @name umbraco.services.backdropService#close + * @methodOf umbraco.services.backdropService + * + * @description + * Raises an event to close the backdrop + * + */ + function close() { + args.element = null; + args.show = false; + eventsService.emit("appState.backdrop", args); + } + + /** + * @ngdoc method + * @name umbraco.services.backdropService#setOpacity + * @methodOf umbraco.services.backdropService + * + * @description + * Raises an event which updates the opacity option on the backdrop + */ + function setOpacity(opacity) { + args.opacity = opacity; + eventsService.emit("appState.backdrop", args); + } + + /** + * @ngdoc method + * @name umbraco.services.backdropService#setHighlight + * @methodOf umbraco.services.backdropService + * + * @description + * Raises an event which updates the element option on the backdrop + */ + function setHighlight(element, preventClick) { + args.element = element; + args.elementPreventClick = preventClick; + eventsService.emit("appState.backdrop", args); + } + + var service = { + open: open, + close: close, + setOpacity: setOpacity, + setHighlight: setHighlight + }; + + return service; + + } + + angular.module("umbraco.services").factory("backdropService", backdropService); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js index e439eda1ba..6aab9a655c 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/contenteditinghelper.service.js @@ -5,7 +5,7 @@ * @description A helper service for most editors, some methods are specific to content/media/member model types but most are used by * all editors to share logic and reduce the amount of replicated code among editors. **/ -function contentEditingHelper(fileManager, $q, $location, $routeParams, notificationsService, serverValidationManager, dialogService, formHelper, appState) { +function contentEditingHelper(fileManager, $q, $location, $routeParams, notificationsService, localizationService, serverValidationManager, dialogService, formHelper, appState) { function isValidIdentifier(id){ //empty id <= 0 @@ -28,7 +28,7 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica return { - /** Used by the content editor and mini content editor to perform saving operations */ + /** Used by the content editor and mini content editor to perform saving operations */ //TODO: Make this a more helpful/reusable method for other form operations! we can simplify this form most forms contentEditorPerformSave: function (args) { if (!angular.isObject(args)) { @@ -100,7 +100,35 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica return deferred.promise; }, + + /** Used by the content editor and media editor to add an info tab to the tabs array (normally known as the properties tab) */ + addInfoTab: function (tabs) { + var infoTab = { + "alias": "_umb_infoTab", + "id": -1, + "label": "Info", + "properties": [] + }; + + // first check if tab is already added + var foundInfoTab = false; + + angular.forEach(tabs, function (tab) { + if (tab.id === infoTab.id && tab.alias === infoTab.alias) { + foundInfoTab = true; + } + }); + + // add info tab if is is not found + if (!foundInfoTab) { + localizationService.localize("general_info").then(function (value) { + infoTab.label = value; + tabs.push(infoTab); + }); + } + + }, /** Returns the action button definitions based on what permissions the user has. The content.allowedActions parameter contains a list of chars, each represents a button by permission so @@ -134,7 +162,8 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica labelKey: "buttons_saveAndPublish", handler: args.methods.saveAndPublish, hotKey: "ctrl+p", - hotKeyWhenHidden: true + hotKeyWhenHidden: true, + alias: "saveAndPublish" }; case "H": //send to publish @@ -143,7 +172,8 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica labelKey: "buttons_saveToPublish", handler: args.methods.sendToPublish, hotKey: "ctrl+p", - hotKeyWhenHidden: true + hotKeyWhenHidden: true, + alias: "sendToPublish" }; case "A": //save @@ -152,7 +182,8 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica labelKey: "buttons_save", handler: args.methods.save, hotKey: "ctrl+s", - hotKeyWhenHidden: true + hotKeyWhenHidden: true, + alias: "save" }; case "Z": //unpublish @@ -161,7 +192,8 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica labelKey: "content_unPublish", handler: args.methods.unPublish, hotKey: "ctrl+u", - hotKeyWhenHidden: true + hotKeyWhenHidden: true, + alias: "unpublish" }; default: return null; @@ -176,10 +208,10 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica var buttonOrder = ["U", "H", "A"]; //Create the first button (primary button) - //We cannot have the Save or SaveAndPublish buttons if they don't have create permissions when we are creating a new item. - //Another tricky rule is if they only have Create + Browse permissions but not Save but if it's being created then they will - // require the Save button in order to create. - //So this code is going to create the primary button (either Publish, SendToPublish, Save) if we are not in create mode + //We cannot have the Save or SaveAndPublish buttons if they don't have create permissions when we are creating a new item. + //Another tricky rule is if they only have Create + Browse permissions but not Save but if it's being created then they will + // require the Save button in order to create. + //So this code is going to create the primary button (either Publish, SendToPublish, Save) if we are not in create mode // or if the user has access to create. if (!args.create || _.contains(args.content.allowedActions, "C")) { for (var b in buttonOrder) { @@ -187,9 +219,9 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica buttons.defaultButton = createButtonDefinition(buttonOrder[b]); break; } - } - //Here's the special check, if the button still isn't set and we are creating and they have create access - //we need to add the Save button + } + //Here's the special check, if the button still isn't set and we are creating and they have create access + //we need to add the Save button if (!buttons.defaultButton && args.create && _.contains(args.content.allowedActions, "C")) { buttons.defaultButton = createButtonDefinition("A"); } @@ -220,6 +252,40 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, notifica } } + // If we have a scheduled publish or unpublish date change the default button to + // "save" and update the label to "save and schedule + if(args.content.releaseDate || args.content.removeDate) { + + // if save button is alread the default don't change it just update the label + if (buttons.defaultButton && buttons.defaultButton.letter === "A") { + buttons.defaultButton.labelKey = "buttons_saveAndSchedule"; + return; + } + + if(buttons.defaultButton && buttons.subButtons && buttons.subButtons.length > 0) { + // save a copy of the default so we can push it to the sub buttons later + var defaultButtonCopy = angular.copy(buttons.defaultButton); + var newSubButtons = []; + + // if save button is not the default button - find it and make it the default + angular.forEach(buttons.subButtons, function (subButton) { + + if (subButton.letter === "A") { + buttons.defaultButton = subButton; + buttons.defaultButton.labelKey = "buttons_saveAndSchedule"; + } else { + newSubButtons.push(subButton); + } + + }); + + // push old default button into subbuttons + newSubButtons.push(defaultButtonCopy); + buttons.subButtons = newSubButtons; + } + + } + return buttons; }, diff --git a/src/Umbraco.Web.UI.Client/src/common/services/events.service.js b/src/Umbraco.Web.UI.Client/src/common/services/events.service.js index 174cc8abe2..e28970336f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/events.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/events.service.js @@ -16,52 +16,16 @@ function eventsService($q, $rootScope) { return { - /** raise an event with a given name, returns an array of promises for each listener */ - emit: function (name, args) { + /** raise an event with a given name */ + emit: function (name, args) { //there are no listeners if (!$rootScope.$$listeners[name]) { return; - //return []; } //send the event $rootScope.$emit(name, args); - - - //PP: I've commented out the below, since we currently dont - // expose the eventsService as a documented api - // and think we need to figure out our usecases for this - // since the below modifies the return value of the then on() method - /* - //setup a deferred promise for each listener - var deferred = []; - for (var i = 0; i < $rootScope.$$listeners[name].length; i++) { - deferred.push($q.defer()); - }*/ - - //create a new event args object to pass to the - // $emit containing methods that will allow listeners - // to return data in an async if required - /* - var eventArgs = { - args: args, - reject: function (a) { - deferred.pop().reject(a); - }, - resolve: function (a) { - deferred.pop().resolve(a); - } - };*/ - - - - /* - //return an array of promises - var promises = _.map(deferred, function(p) { - return p.promise; - }); - return promises;*/ }, /** subscribe to a method, or use scope.$on = same thing */ diff --git a/src/Umbraco.Web.UI.Client/src/common/services/help.service.js b/src/Umbraco.Web.UI.Client/src/common/services/help.service.js index 6d7d341a57..77621eee9c 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/help.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/help.service.js @@ -1,5 +1,5 @@ angular.module('umbraco.services') - .factory('helpService', function ($http, $q){ + .factory('helpService', function ($http, $q, umbRequestHelper) { var helpTopics = {}; var defaultUrl = "http://our.umbraco.org/rss/help"; @@ -47,8 +47,6 @@ angular.module('umbraco.services') return deferred.promise; } - - var service = { findHelp: function (args) { var url = service.getUrl(defaultUrl, args); @@ -60,6 +58,26 @@ angular.module('umbraco.services') return fetchUrl(url); }, + getContextHelpForPage: function (section, tree, baseurl) { + + var qs = "?section=" + section + "&tree=" + tree; + + if (tree) { + qs += "&tree=" + tree; + } + + if (baseurl) { + qs += "&baseurl=" + encodeURIComponent(baseurl); + } + + var url = umbRequestHelper.getApiUrl( + "helpApiBaseUrl", + "GetContextHelpForPage" + qs); + + return umbRequestHelper.resourcePromise( + $http.get(url), "Failed to get lessons content"); + }, + getUrl: function(url, args){ return url + "?" + $.param(args); } diff --git a/src/Umbraco.Web.UI.Client/src/common/services/history.service.js b/src/Umbraco.Web.UI.Client/src/common/services/history.service.js index 75963112e5..ee02efc4a0 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/history.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/history.service.js @@ -115,6 +115,27 @@ angular.module('umbraco.services') */ getCurrent: function(){ return nArray; - } + }, + + /** + * @ngdoc method + * @name umbraco.services.historyService#getLastAccessedItemForSection + * @methodOf umbraco.services.historyService + * + * @description + * Method to return the item that was last accessed in the given section + * + * @param {string} sectionAlias Alias of the section to return the last accessed item for. + */ + getLastAccessedItemForSection: function (sectionAlias) { + for (var i = 0, len = nArray.length; i < len; i++) { + var item = nArray[i]; + if (item.link.indexOf(sectionAlias + "/") === 0) { + return item; + } + } + + return null; + } }; }); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/services/listviewhelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/listviewhelper.service.js index 84bb4333a2..2ffdc3f1dd 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/listviewhelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/listviewhelper.service.js @@ -269,7 +269,8 @@ var isSelected = false; for (var i = 0; selection.length > i; i++) { var selectedItem = selection[i]; - if (item.id === selectedItem.id || item.key === selectedItem.key) { + // if item.id is 2147483647 (int.MaxValue) use item.key + if ((item.id !== 2147483647 && item.id === selectedItem.id) || item.key === selectedItem.key) { isSelected = true; } } @@ -294,7 +295,8 @@ function deselectItem(item, selection) { for (var i = 0; selection.length > i; i++) { var selectedItem = selection[i]; - if (item.id === selectedItem.id) { + // if item.id is 2147483647 (int.MaxValue) use item.key + if ((item.id !== 2147483647 && item.id === selectedItem.id) || item.key === selectedItem.key) { selection.splice(i, 1); item.selected = false; } @@ -366,7 +368,7 @@ var item = items[i]; if (checkbox.checked) { - selection.push({ id: item.id }); + selection.push({ id: item.id, key: item.key }); } else { clearSelection = true; } @@ -405,7 +407,8 @@ for (var selectedIndex = 0; selection.length > selectedIndex; selectedIndex++) { var selectedItem = selection[selectedIndex]; - if (item.id === selectedItem.id) { + // if item.id is 2147483647 (int.MaxValue) use item.key + if ((item.id !== 2147483647 && item.id === selectedItem.id) || item.key === selectedItem.key) { numberOfSelectedItem++; } } diff --git a/src/Umbraco.Web.UI.Client/src/common/services/menuactions.service.js b/src/Umbraco.Web.UI.Client/src/common/services/menuactions.service.js index c7438a1c47..7200e1f6f8 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/menuactions.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/menuactions.service.js @@ -8,9 +8,27 @@ * @description * Defines the methods that are called when menu items declare only an action to execute */ -function umbracoMenuActions($q, treeService, $location, navigationService, appState, localizationService, userResource) { +function umbracoMenuActions($q, treeService, $location, navigationService, appState, localizationService, userResource, umbRequestHelper, notificationsService) { return { + + "ExportMember": function(args) { + var url = umbRequestHelper.getApiUrl( + "memberApiBaseUrl", + "ExportMemberData", + [{ key: args.entity.id }]); + + umbRequestHelper.downloadFile(url).then(function() { + localizationService.localize("speechBubbles_memberExportedSuccess").then(function (value) { + notificationsService.success(value); + }) + }, function(data) { + localizationService.localize("speechBubbles_memberExportedError").then(function (value) { + notificationsService.error(value); + }) + }); + + }, "DisableUser": function(args) { localizationService.localize("defaultdialogs_confirmdisable").then(function (txtConfirmDisable) { diff --git a/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js b/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js index 9687e5c40d..20e73f9e06 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js @@ -524,37 +524,6 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo return service.userDialog; }, - /** - * @ngdoc method - * @name umbraco.services.navigationService#showUserDialog - * @methodOf umbraco.services.navigationService - * - * @description - * Opens the user dialog, next to the sections navigation - * template is located in views/common/dialogs/user.html - */ - showHelpDialog: function () { - // hide tray and close user dialog - service.hideTray(); - if (service.userDialog) { - service.userDialog.close(); - } - - if(service.helpDialog){ - service.helpDialog.close(); - service.helpDialog = undefined; - } - - service.helpDialog = dialogService.open( - { - template: "views/common/dialogs/help.html", - modalClass: "umb-modal-left", - show: true - }); - - return service.helpDialog; - }, - /** * @ngdoc method * @name umbraco.services.navigationService#showDialog diff --git a/src/Umbraco.Web.UI.Client/src/common/services/tour.service.js b/src/Umbraco.Web.UI.Client/src/common/services/tour.service.js new file mode 100644 index 0000000000..28ac7f6485 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/services/tour.service.js @@ -0,0 +1,281 @@ +/** + @ngdoc service + * @name umbraco.services.tourService + * + * @description + * Added in Umbraco 7.8. Application-wide service for handling tours. + */ +(function () { + 'use strict'; + + function tourService(eventsService, currentUserResource, $q, tourResource) { + + var tours = []; + var currentTour = null; + + /** + * Registers all tours from the server and returns a promise + */ + function registerAllTours() { + tours = []; + return tourResource.getTours().then(function(tourFiles) { + angular.forEach(tourFiles, function (tourFile) { + angular.forEach(tourFile.tours, function(newTour) { + validateTour(newTour); + validateTourRegistration(newTour); + tours.push(newTour); + }); + }); + eventsService.emit("appState.tour.updatedTours", tours); + }); + } + + /** + * Method to return all of the tours as a new instance + */ + function getTours() { + return tours; + } + + /** + * @ngdoc method + * @name umbraco.services.tourService#startTour + * @methodOf umbraco.services.tourService + * + * @description + * Raises an event to start a tour + * @param {Object} tour The tour which should be started + */ + function startTour(tour) { + validateTour(tour); + eventsService.emit("appState.tour.start", tour); + currentTour = tour; + } + + /** + * @ngdoc method + * @name umbraco.services.tourService#endTour + * @methodOf umbraco.services.tourService + * + * @description + * Raises an event to end the current tour + */ + function endTour(tour) { + eventsService.emit("appState.tour.end", tour); + currentTour = null; + } + + /** + * Disables a tour for the user, raises an event and returns a promise + * @param {any} tour + */ + function disableTour(tour) { + var deferred = $q.defer(); + tour.disabled = true; + currentUserResource + .saveTourStatus({ alias: tour.alias, disabled: tour.disabled, completed: tour.completed }).then( + function() { + eventsService.emit("appState.tour.end", tour); + currentTour = null; + deferred.resolve(tour); + }); + return deferred.promise; + } + + /** + * @ngdoc method + * @name umbraco.services.tourService#completeTour + * @methodOf umbraco.services.tourService + * + * @description + * Completes a tour for the user, raises an event and returns a promise + * @param {Object} tour The tour which should be completed + */ + function completeTour(tour) { + var deferred = $q.defer(); + tour.completed = true; + currentUserResource + .saveTourStatus({ alias: tour.alias, disabled: tour.disabled, completed: tour.completed }).then( + function() { + eventsService.emit("appState.tour.complete", tour); + currentTour = null; + deferred.resolve(tour); + }); + return deferred.promise; + } + + /** + * @ngdoc method + * @name umbraco.services.tourService#getCurrentTour + * @methodOf umbraco.services.tourService + * + * @description + * Returns the current tour + * @returns {Object} Returns the current tour + */ + function getCurrentTour() { + //TODO: This should be reset if a new user logs in + return currentTour; + } + + /** + * @ngdoc method + * @name umbraco.services.tourService#getGroupedTours + * @methodOf umbraco.services.tourService + * + * @description + * Returns a promise of grouped tours with the current user statuses + * @returns {Array} All registered tours grouped by tour group + */ + function getGroupedTours() { + var deferred = $q.defer(); + var tours = getTours(); + setTourStatuses(tours).then(function() { + var groupedTours = []; + tours.forEach(function (item) { + + var groupExists = false; + var newGroup = { + "group": "", + "tours": [] + }; + + groupedTours.forEach(function(group){ + // extend existing group if it is already added + if(group.group === item.group) { + if(item.groupOrder) { + group.groupOrder = item.groupOrder + } + groupExists = true; + group.tours.push(item) + } + }); + + // push new group to array if it doesn't exist + if(!groupExists) { + newGroup.group = item.group; + if(item.groupOrder) { + newGroup.groupOrder = item.groupOrder + } + newGroup.tours.push(item); + groupedTours.push(newGroup); + } + + }); + + deferred.resolve(groupedTours); + }); + return deferred.promise; + } + + /** + * @ngdoc method + * @name umbraco.services.tourService#getTourByAlias + * @methodOf umbraco.services.tourService + * + * @description + * Returns a promise of the tour found by alias with the current user statuses + * @param {Object} tourAlias The tour alias of the tour which should be returned + * @returns {Object} Tour object + */ + function getTourByAlias(tourAlias) { + var deferred = $q.defer(); + var tours = getTours(); + setTourStatuses(tours).then(function () { + var tour = _.findWhere(tours, { alias: tourAlias }); + deferred.resolve(tour); + }); + return deferred.promise; + } + + /////////// + + /** + * Validates a tour object and makes sure it consists of the correct properties needed to start a tour + * @param {any} tour + */ + function validateTour(tour) { + + if (!tour) { + throw "A tour is not specified"; + } + + if (!tour.alias) { + throw "A tour alias is required"; + } + + if (!tour.steps) { + throw "Tour " + tour.alias + " is missing tour steps"; + } + + if (tour.steps && tour.steps.length === 0) { + throw "Tour " + tour.alias + " is missing tour steps"; + } + + if (tour.requiredSections.length === 0) { + throw "Tour " + tour.alias + " is missing the required sections"; + } + } + + /** + * Validates a tour before it gets registered in the service + * @param {any} tour + */ + function validateTourRegistration(tour) { + // check for existing tours with the same alias + angular.forEach(tours, function (existingTour) { + if (existingTour.alias === tour.alias) { + throw "A tour with the alias " + tour.alias + " is already registered"; + } + }); + } + + /** + * Based on the tours given, this will set each of the tour statuses (disabled/completed) based on what is stored against the current user + * @param {any} tours + */ + function setTourStatuses(tours) { + + var deferred = $q.defer(); + currentUserResource.getTours().then(function (storedTours) { + + angular.forEach(storedTours, function (storedTour) { + if (storedTour.completed === true) { + angular.forEach(tours, function (tour) { + if (storedTour.alias === tour.alias) { + tour.completed = true; + } + }); + } + if (storedTour.disabled === true) { + angular.forEach(tours, function (tour) { + if (storedTour.alias === tour.alias) { + tour.disabled = true; + } + }); + } + }); + + deferred.resolve(tours); + }); + return deferred.promise; + } + + var service = { + registerAllTours: registerAllTours, + startTour: startTour, + endTour: endTour, + disableTour: disableTour, + completeTour: completeTour, + getCurrentTour: getCurrentTour, + getGroupedTours: getGroupedTours, + getTourByAlias: getTourByAlias + }; + + return service; + + } + + angular.module("umbraco.services").factory("tourService", tourService); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js b/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js index 5fc2416927..78dec2f065 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js @@ -14,7 +14,7 @@ if (!model) { return null; } - var trimmed = _.omit(model, ["confirm", "generatedPassword"]) + var trimmed = _.omit(model, ["confirm", "generatedPassword"]); //ensure that the pass value is null if all child properties are null var allNull = true; @@ -56,7 +56,7 @@ }); var saveProperties = _.map(realProperties, function (p) { - var saveProperty = _.pick(p, 'id', 'alias', 'description', 'validation', 'label', 'sortOrder', 'dataTypeId', 'groupId', 'memberCanEdit', 'showOnMemberProfile'); + var saveProperty = _.pick(p, 'id', 'alias', 'description', 'validation', 'label', 'sortOrder', 'dataTypeId', 'groupId', 'memberCanEdit', 'showOnMemberProfile', 'isSensitiveData'); return saveProperty; }); @@ -242,8 +242,8 @@ var propGroups = _.find(genericTab.properties, function (item) { return item.alias === "_umb_membergroup"; }); - saveModel.email = propEmail.value; - saveModel.username = propLogin.value; + saveModel.email = propEmail.value.trim(); + saveModel.username = propLogin.value.trim(); saveModel.password = this.formatChangePasswordModel(propPass.value); @@ -267,10 +267,10 @@ // by looking at the key switch (foundAlias[0]) { case "umbracoMemberLockedOut": - saveModel.isLockedOut = prop.value.toString() === "1" ? true : false; + saveModel.isLockedOut = prop.value ? (prop.value.toString() === "1" ? true : false) : false; break; case "umbracoMemberApproved": - saveModel.isApproved = prop.value.toString() === "1" ? true : false; + saveModel.isApproved = prop.value ? (prop.value.toString() === "1" ? true : false) : true; break; case "umbracoMemberComments": saveModel.comments = prop.value; @@ -304,14 +304,14 @@ _.each(tab.properties, function (prop) { //don't include the custom generic tab properties - if (!prop.alias.startsWith("_umb_")) { + //don't include a property that is marked readonly + if (!prop.alias.startsWith("_umb_") && !prop.readonly) { saveModel.properties.push({ id: prop.id, alias: prop.alias, value: prop.value }); } - }); }); @@ -324,22 +324,13 @@ //this is basically the same as for media but we need to explicitly add some extra properties var saveModel = this.formatMediaPostData(displayModel, action); - var genericTab = _.find(displayModel.tabs, function (item) { - return item.id === 0; - }); - - var propExpireDate = _.find(genericTab.properties, function (item) { - return item.alias === "_umb_expiredate"; - }); - var propReleaseDate = _.find(genericTab.properties, function (item) { - return item.alias === "_umb_releasedate"; - }); - var propTemplate = _.find(genericTab.properties, function (item) { - return item.alias === "_umb_template"; - }); - saveModel.expireDate = propExpireDate ? propExpireDate.value : null; - saveModel.releaseDate = propReleaseDate ? propReleaseDate.value : null; - saveModel.templateAlias = propTemplate ? propTemplate.value : null; + var propExpireDate = displayModel.removeDate; + var propReleaseDate = displayModel.releaseDate; + var propTemplate = displayModel.template; + + saveModel.expireDate = propExpireDate ? propExpireDate : null; + saveModel.releaseDate = propReleaseDate ? propReleaseDate : null; + saveModel.templateAlias = propTemplate ? propTemplate : null; return saveModel; } @@ -347,4 +338,4 @@ } angular.module('umbraco.services').factory('umbDataFormatter', umbDataFormatter); -})(); \ No newline at end of file +})(); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js index 3732e25403..d950d39619 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/umbrequesthelper.service.js @@ -230,10 +230,12 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ //success callback //reset the tabs and set the active one - _.each(data.tabs, function (item) { - item.active = false; - }); - data.tabs[activeTabIndex].active = true; + if(data.tabs && data.tabs.length > 0) { + _.each(data.tabs, function (item) { + item.active = false; + }); + data.tabs[activeTabIndex].active = true; + } //the data returned is the up-to-date data so the UI will refresh deferred.resolve(data); @@ -331,6 +333,122 @@ function umbRequestHelper($http, $q, umbDataFormatter, angularHelper, dialogServ failureCallback.apply(this, [data, status, headers, config]); } }); + }, + + /** + * Downloads a file to the client using AJAX/XHR + * Based on an implementation here: web.student.tuwien.ac.at/~e0427417/jsdownload.html + * See https://stackoverflow.com/a/24129082/694494 + */ + downloadFile : function (httpPath) { + + var deferred = $q.defer(); + + // Use an arraybuffer + $http.get(httpPath, { responseType: 'arraybuffer' }) + .success(function (data, status, headers) { + + var octetStreamMime = 'application/octet-stream'; + var success = false; + + // Get the headers + headers = headers(); + + // Get the filename from the x-filename header or default to "download.bin" + var filename = headers['x-filename'] || 'download.bin'; + + // Determine the content type from the header or default to "application/octet-stream" + var contentType = headers['content-type'] || octetStreamMime; + + try { + // Try using msSaveBlob if supported + console.log("Trying saveBlob method ..."); + var blob = new Blob([data], { type: contentType }); + if (navigator.msSaveBlob) + navigator.msSaveBlob(blob, filename); + else { + // Try using other saveBlob implementations, if available + var saveBlob = navigator.webkitSaveBlob || navigator.mozSaveBlob || navigator.saveBlob; + if (saveBlob === undefined) throw "Not supported"; + saveBlob(blob, filename); + } + console.log("saveBlob succeeded"); + success = true; + } catch (ex) { + console.log("saveBlob method failed with the following exception:"); + console.log(ex); + } + + if (!success) { + // Get the blob url creator + var urlCreator = window.URL || window.webkitURL || window.mozURL || window.msURL; + if (urlCreator) { + // Try to use a download link + var link = document.createElement('a'); + if ('download' in link) { + // Try to simulate a click + try { + // Prepare a blob URL + console.log("Trying download link method with simulated click ..."); + var blob = new Blob([data], { type: contentType }); + var url = urlCreator.createObjectURL(blob); + link.setAttribute('href', url); + + // Set the download attribute (Supported in Chrome 14+ / Firefox 20+) + link.setAttribute("download", filename); + + // Simulate clicking the download link + var event = document.createEvent('MouseEvents'); + event.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); + link.dispatchEvent(event); + console.log("Download link method with simulated click succeeded"); + success = true; + + } catch (ex) { + console.log("Download link method with simulated click failed with the following exception:"); + console.log(ex); + } + } + + if (!success) { + // Fallback to window.location method + try { + // Prepare a blob URL + // Use application/octet-stream when using window.location to force download + console.log("Trying download link method with window.location ..."); + var blob = new Blob([data], { type: octetStreamMime }); + var url = urlCreator.createObjectURL(blob); + window.location = url; + console.log("Download link method with window.location succeeded"); + success = true; + } catch (ex) { + console.log("Download link method with window.location failed with the following exception:"); + console.log(ex); + } + } + + } + } + + if (!success) { + // Fallback to window.open method + console.log("No methods worked for saving the arraybuffer, using last resort window.open"); + window.open(httpPath, '_blank', ''); + } + + deferred.resolve(); + }) + .error(function (data, status) { + console.log("Request failed with status: " + status); + + deferred.reject({ + errorMsg: "An error occurred downloading the file", + data: data, + status: status + }); + }); + + return deferred.promise; } }; } diff --git a/src/Umbraco.Web.UI.Client/src/common/services/usershelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/usershelper.service.js index 7d96e17d1f..3d6ad8b265 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/usershelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/usershelper.service.js @@ -11,6 +11,18 @@ { "value": 3, "name": "Invited", "key": "Invited", "color": "warning" } ]; + angular.forEach(userStates, function (userState) { + var key = "user_state" + userState.key; + localizationService.localize(key).then(function (value) { + var reg = /^\[[\S\s]*]$/g; + var result = reg.test(value); + if (result === false) { + // Only translate if key exists + userState.name = value; + } + }); + }); + function getUserStateFromValue(value) { var foundUserState; angular.forEach(userStates, function (userState) { diff --git a/src/Umbraco.Web.UI.Client/src/common/services/util.service.js b/src/Umbraco.Web.UI.Client/src/common/services/util.service.js index 067ef60492..11444ff65b 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/util.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/util.service.js @@ -1,420 +1,444 @@ /*Contains multiple services for various helper tasks */ function versionHelper() { - return { + return { - //see: https://gist.github.com/TheDistantSea/8021359 - versionCompare: function (v1, v2, options) { - var lexicographical = options && options.lexicographical, - zeroExtend = options && options.zeroExtend, - v1parts = v1.split('.'), - v2parts = v2.split('.'); + //see: https://gist.github.com/TheDistantSea/8021359 + versionCompare: function (v1, v2, options) { + var lexicographical = options && options.lexicographical, + zeroExtend = options && options.zeroExtend, + v1parts = v1.split('.'), + v2parts = v2.split('.'); - function isValidPart(x) { - return (lexicographical ? /^\d+[A-Za-z]*$/ : /^\d+$/).test(x); - } + function isValidPart(x) { + return (lexicographical ? /^\d+[A-Za-z]*$/ : /^\d+$/).test(x); + } - if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) { - return NaN; - } + if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) { + return NaN; + } - if (zeroExtend) { - while (v1parts.length < v2parts.length) { - v1parts.push("0"); + if (zeroExtend) { + while (v1parts.length < v2parts.length) { + v1parts.push("0"); + } + while (v2parts.length < v1parts.length) { + v2parts.push("0"); + } + } + + if (!lexicographical) { + v1parts = v1parts.map(Number); + v2parts = v2parts.map(Number); + } + + for (var i = 0; i < v1parts.length; ++i) { + if (v2parts.length === i) { + return 1; + } + + if (v1parts[i] === v2parts[i]) { + continue; + } + else if (v1parts[i] > v2parts[i]) { + return 1; + } + else { + return -1; + } + } + + if (v1parts.length !== v2parts.length) { + return -1; + } + + return 0; } - while (v2parts.length < v1parts.length) { - v2parts.push("0"); - } - } - - if (!lexicographical) { - v1parts = v1parts.map(Number); - v2parts = v2parts.map(Number); - } - - for (var i = 0; i < v1parts.length; ++i) { - if (v2parts.length === i) { - return 1; - } - - if (v1parts[i] === v2parts[i]) { - continue; - } - else if (v1parts[i] > v2parts[i]) { - return 1; - } - else { - return -1; - } - } - - if (v1parts.length !== v2parts.length) { - return -1; - } - - return 0; - } - }; + }; } angular.module('umbraco.services').factory('versionHelper', versionHelper); function dateHelper() { - return { + return { - convertToServerStringTime: function (momentLocal, serverOffsetMinutes, format) { + convertToServerStringTime: function (momentLocal, serverOffsetMinutes, format) { - //get the formatted offset time in HH:mm (server time offset is in minutes) - var formattedOffset = (serverOffsetMinutes > 0 ? "+" : "-") + - moment() - .startOf('day') - .minutes(Math.abs(serverOffsetMinutes)) - .format('HH:mm'); + //get the formatted offset time in HH:mm (server time offset is in minutes) + var formattedOffset = (serverOffsetMinutes > 0 ? "+" : "-") + + moment() + .startOf('day') + .minutes(Math.abs(serverOffsetMinutes)) + .format('HH:mm'); - var server = moment.utc(momentLocal).utcOffset(formattedOffset); - return server.format(format ? format : "YYYY-MM-DD HH:mm:ss"); - }, + var server = moment.utc(momentLocal).utcOffset(formattedOffset); + return server.format(format ? format : "YYYY-MM-DD HH:mm:ss"); + }, - convertToLocalMomentTime: function (strVal, serverOffsetMinutes) { + convertToLocalMomentTime: function (strVal, serverOffsetMinutes) { - //get the formatted offset time in HH:mm (server time offset is in minutes) - var formattedOffset = (serverOffsetMinutes > 0 ? "+" : "-") + - moment() - .startOf('day') - .minutes(Math.abs(serverOffsetMinutes)) - .format('HH:mm'); + //get the formatted offset time in HH:mm (server time offset is in minutes) + var formattedOffset = (serverOffsetMinutes > 0 ? "+" : "-") + + moment() + .startOf('day') + .minutes(Math.abs(serverOffsetMinutes)) + .format('HH:mm'); - //convert to the iso string format - var isoFormat = moment(strVal).format("YYYY-MM-DDTHH:mm:ss") + formattedOffset; + //if the string format already denotes that it's in "Roundtrip UTC" format (i.e. "2018-02-07T00:20:38.173Z") + //otherwise known as https://en.wikipedia.org/wiki/ISO_8601. This is the default format returned from the server + //since that is the default formatter for newtonsoft.json. When it is in this format, we need to tell moment + //to load the date as UTC so it's not changed, otherwise load it normally + var isoFormat; + if (strVal.indexOf("T") > -1 && strVal.endsWith("Z")) { + isoFormat = moment.utc(strVal).format("YYYY-MM-DDTHH:mm:ss") + formattedOffset; + } + else { + isoFormat = moment(strVal).format("YYYY-MM-DDTHH:mm:ss") + formattedOffset; + } - //create a moment with the iso format which will include the offset with the correct time - // then convert it to local time - return moment.parseZone(isoFormat).local(); - } + //create a moment with the iso format which will include the offset with the correct time + // then convert it to local time + return moment.parseZone(isoFormat).local(); + }, - }; + getLocalDate: function (date, culture, format) { + if (date) { + var dateVal; + var serverOffset = Umbraco.Sys.ServerVariables.application.serverTimeOffset; + var localOffset = new Date().getTimezoneOffset(); + var serverTimeNeedsOffsetting = -serverOffset !== localOffset; + if (serverTimeNeedsOffsetting) { + dateVal = this.convertToLocalMomentTime(date, serverOffset); + } else { + dateVal = moment(date, 'YYYY-MM-DD HH:mm:ss'); + } + return dateVal.locale(culture).format(format); + } + } + + }; } angular.module('umbraco.services').factory('dateHelper', dateHelper); function packageHelper(assetsService, treeService, eventsService, $templateCache) { - return { + return { - /** Called when a package is installed, this resets a bunch of data and ensures the new package assets are loaded in */ - packageInstalled: function () { + /** Called when a package is installed, this resets a bunch of data and ensures the new package assets are loaded in */ + packageInstalled: function () { - //clears the tree - treeService.clearCache(); + //clears the tree + treeService.clearCache(); - //clears the template cache - $templateCache.removeAll(); + //clears the template cache + $templateCache.removeAll(); - //emit event to notify anything else - eventsService.emit("app.reInitialize"); - } + //emit event to notify anything else + eventsService.emit("app.reInitialize"); + } - }; + }; } angular.module('umbraco.services').factory('packageHelper', packageHelper); //TODO: I believe this is obsolete function umbPhotoFolderHelper($compile, $log, $timeout, $filter, imageHelper, mediaHelper, umbRequestHelper) { - return { - /** sets the image's url, thumbnail and if its a folder */ - setImageData: function (img) { + return { + /** sets the image's url, thumbnail and if its a folder */ + setImageData: function (img) { - img.isFolder = !mediaHelper.hasFilePropertyType(img); + img.isFolder = !mediaHelper.hasFilePropertyType(img); - if (!img.isFolder) { - img.thumbnail = mediaHelper.resolveFile(img, true); - img.image = mediaHelper.resolveFile(img, false); - } - }, + if (!img.isFolder) { + img.thumbnail = mediaHelper.resolveFile(img, true); + img.image = mediaHelper.resolveFile(img, false); + } + }, - /** sets the images original size properties - will check if it is a folder and if so will just make it square */ - setOriginalSize: function (img, maxHeight) { - //set to a square by default - img.originalWidth = maxHeight; - img.originalHeight = maxHeight; + /** sets the images original size properties - will check if it is a folder and if so will just make it square */ + setOriginalSize: function (img, maxHeight) { + //set to a square by default + img.originalWidth = maxHeight; + img.originalHeight = maxHeight; - var widthProp = _.find(img.properties, function (v) { return (v.alias === "umbracoWidth"); }); - if (widthProp && widthProp.value) { - img.originalWidth = parseInt(widthProp.value, 10); - if (isNaN(img.originalWidth)) { - img.originalWidth = maxHeight; + var widthProp = _.find(img.properties, function (v) { return (v.alias === "umbracoWidth"); }); + if (widthProp && widthProp.value) { + img.originalWidth = parseInt(widthProp.value, 10); + if (isNaN(img.originalWidth)) { + img.originalWidth = maxHeight; + } + } + var heightProp = _.find(img.properties, function (v) { return (v.alias === "umbracoHeight"); }); + if (heightProp && heightProp.value) { + img.originalHeight = parseInt(heightProp.value, 10); + if (isNaN(img.originalHeight)) { + img.originalHeight = maxHeight; + } + } + }, + + /** sets the image style which get's used in the angular markup */ + setImageStyle: function (img, width, height, rightMargin, bottomMargin) { + img.style = { width: width + "px", height: height + "px", "margin-right": rightMargin + "px", "margin-bottom": bottomMargin + "px" }; + img.thumbStyle = { + "background-image": "url('" + img.thumbnail + "')", + "background-repeat": "no-repeat", + "background-position": "center", + "background-size": Math.min(width, img.originalWidth) + "px " + Math.min(height, img.originalHeight) + "px" + }; + }, + + /** gets the image's scaled wdith based on the max row height */ + getScaledWidth: function (img, maxHeight) { + var scaled = img.originalWidth * maxHeight / img.originalHeight; + return scaled; + //round down, we don't want it too big even by half a pixel otherwise it'll drop to the next row + //return Math.floor(scaled); + }, + + /** returns the target row width taking into account how many images will be in the row and removing what the margin is */ + getTargetWidth: function (imgsPerRow, maxRowWidth, margin) { + //take into account the margin, we will have 1 less margin item than we have total images + return (maxRowWidth - ((imgsPerRow - 1) * margin)); + }, + + /** + This will determine the row/image height for the next collection of images which takes into account the + ideal image count per row. It will check if a row can be filled with this ideal count and if not - if there + are additional images available to fill the row it will keep calculating until they fit. + + It will return the calculated height and the number of images for the row. + + targetHeight = optional; + */ + getRowHeightForImages: function (imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin, targetHeight) { + + var idealImages = imgs.slice(0, idealImgPerRow); + //get the target row width without margin + var targetRowWidth = this.getTargetWidth(idealImages.length, maxRowWidth, margin); + //this gets the image with the smallest height which equals the maximum we can scale up for this image block + var maxScaleableHeight = this.getMaxScaleableHeight(idealImages, maxRowHeight); + //if the max scale height is smaller than the min display height, we'll use the min display height + targetHeight = targetHeight !== undefined ? targetHeight : Math.max(maxScaleableHeight, minDisplayHeight); + + var attemptedRowHeight = this.performGetRowHeight(idealImages, targetRowWidth, minDisplayHeight, targetHeight); + + if (attemptedRowHeight != null) { + + //if this is smaller than the min display then we need to use the min display, + // which means we'll need to remove one from the row so we can scale up to fill the row + if (attemptedRowHeight < minDisplayHeight) { + + if (idealImages.length > 1) { + + //we'll generate a new targetHeight that is halfway between the max and the current and recurse, passing in a new targetHeight + targetHeight += Math.floor((maxRowHeight - targetHeight) / 2); + return this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow - 1, margin, targetHeight); + } + else { + //this will occur when we only have one image remaining in the row but it's still going to be too wide even when + // using the minimum display height specified. In this case we're going to have to just crop the image in it's center + // using the minimum display height and the full row width + return { height: minDisplayHeight, imgCount: 1 }; + } + } + else { + //success! + return { height: attemptedRowHeight, imgCount: idealImages.length }; + } + } + + //we know the width will fit in a row, but we now need to figure out if we can fill + // the entire row in the case that we have more images remaining than the idealImgPerRow. + + if (idealImages.length === imgs.length) { + //we have no more remaining images to fill the space, so we'll just use the calc height + return { height: targetHeight, imgCount: idealImages.length }; + } + else if (idealImages.length === 1) { + //this will occur when we only have one image remaining in the row to process but it's not really going to fit ideally + // in the row. + return { height: minDisplayHeight, imgCount: 1 }; + } + else if (idealImages.length === idealImgPerRow && targetHeight < maxRowHeight) { + + //if we're already dealing with the ideal images per row and it's not quite wide enough, we can scale up a little bit so + // long as the targetHeight is currently less than the maxRowHeight. The scale up will be half-way between our current + // target height and the maxRowHeight (we won't loop forever though - if there's a difference of 5 px we'll just quit) + + while (targetHeight < maxRowHeight && (maxRowHeight - targetHeight) > 5) { + targetHeight += Math.floor((maxRowHeight - targetHeight) / 2); + attemptedRowHeight = this.performGetRowHeight(idealImages, targetRowWidth, minDisplayHeight, targetHeight); + if (attemptedRowHeight != null) { + //success! + return { height: attemptedRowHeight, imgCount: idealImages.length }; + } + } + + //Ok, we couldn't actually scale it up with the ideal row count we'll just recurse with a lesser image count. + return this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow - 1, margin); + } + else if (targetHeight === maxRowHeight) { + + //This is going to happen when: + // * We can fit a list of images in a row, but they come up too short (based on minDisplayHeight) + // * Then we'll try to remove an image, but when we try to scale to fit, the width comes up too narrow but the images are already at their + // maximum height (maxRowHeight) + // * So we're stuck, we cannot precicely fit the current list of images, so we'll render a row that will be max height but won't be wide enough + // which is better than rendering a row that is shorter than the minimum since that could be quite small. + + return { height: targetHeight, imgCount: idealImages.length }; + } + else { + + //we have additional images so we'll recurse and add 1 to the idealImgPerRow until it fits + return this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow + 1, margin); + } + + }, + + performGetRowHeight: function (idealImages, targetRowWidth, minDisplayHeight, targetHeight) { + + var currRowWidth = 0; + + for (var i = 0; i < idealImages.length; i++) { + var scaledW = this.getScaledWidth(idealImages[i], targetHeight); + currRowWidth += scaledW; + } + + if (currRowWidth > targetRowWidth) { + //get the new scaled height to fit + var newHeight = targetRowWidth * targetHeight / currRowWidth; + + return newHeight; + } + else if (idealImages.length === 1 && (currRowWidth <= targetRowWidth) && !idealImages[0].isFolder) { + //if there is only one image, then return the target height + return targetHeight; + } + else if (currRowWidth / targetRowWidth > 0.90) { + //it's close enough, it's at least 90% of the width so we'll accept it with the target height + return targetHeight; + } + else { + //if it's not successful, return null + return null; + } + }, + + /** builds an image grid row */ + buildRow: function (imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin, totalRemaining) { + var currRowWidth = 0; + var row = { images: [] }; + + var imageRowHeight = this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin); + var targetWidth = this.getTargetWidth(imageRowHeight.imgCount, maxRowWidth, margin); + + var sizes = []; + //loop through the images we know fit into the height + for (var i = 0; i < imageRowHeight.imgCount; i++) { + //get the lower width to ensure it always fits + var scaledWidth = Math.floor(this.getScaledWidth(imgs[i], imageRowHeight.height)); + + if (currRowWidth + scaledWidth <= targetWidth) { + currRowWidth += scaledWidth; + sizes.push({ + width: scaledWidth, + //ensure that the height is rounded + height: Math.round(imageRowHeight.height) + }); + row.images.push(imgs[i]); + } + else if (imageRowHeight.imgCount === 1 && row.images.length === 0) { + //the image is simply too wide, we'll crop/center it + sizes.push({ + width: maxRowWidth, + //ensure that the height is rounded + height: Math.round(imageRowHeight.height) + }); + row.images.push(imgs[i]); + } + else { + //the max width has been reached + break; + } + } + + //loop through the images for the row and apply the styles + for (var j = 0; j < row.images.length; j++) { + var bottomMargin = margin; + //make the margin 0 for the last one + if (j === (row.images.length - 1)) { + margin = 0; + } + this.setImageStyle(row.images[j], sizes[j].width, sizes[j].height, margin, bottomMargin); + } + + if (row.images.length === 1 && totalRemaining > 1) { + //if there's only one image on the row and there are more images remaining, set the container to max width + row.images[0].style.width = maxRowWidth + "px"; + } + + + return row; + }, + + /** Returns the maximum image scaling height for the current image collection */ + getMaxScaleableHeight: function (imgs, maxRowHeight) { + + var smallestHeight = _.min(imgs, function (item) { return item.originalHeight; }).originalHeight; + + //adjust the smallestHeight if it is larger than the static max row height + if (smallestHeight > maxRowHeight) { + smallestHeight = maxRowHeight; + } + return smallestHeight; + }, + + /** Creates the image grid with calculated widths/heights for images to fill the grid nicely */ + buildGrid: function (images, maxRowWidth, maxRowHeight, startingIndex, minDisplayHeight, idealImgPerRow, margin, imagesOnly) { + + var rows = []; + var imagesProcessed = 0; + + //first fill in all of the original image sizes and URLs + for (var i = startingIndex; i < images.length; i++) { + var item = images[i]; + + this.setImageData(item); + this.setOriginalSize(item, maxRowHeight); + + if (imagesOnly && !item.isFolder && !item.thumbnail) { + images.splice(i, 1); + i--; + } + } + + while ((imagesProcessed + startingIndex) < images.length) { + //get the maxHeight for the current un-processed images + var currImgs = images.slice(imagesProcessed); + + //build the row + var remaining = images.length - imagesProcessed; + var row = this.buildRow(currImgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin, remaining); + if (row.images.length > 0) { + rows.push(row); + imagesProcessed += row.images.length; + } + else { + + if (currImgs.length > 0) { + throw "Could not fill grid with all images, images remaining: " + currImgs.length; + } + + //if there was nothing processed, exit + break; + } + } + + return rows; } - } - var heightProp = _.find(img.properties, function (v) { return (v.alias === "umbracoHeight"); }); - if (heightProp && heightProp.value) { - img.originalHeight = parseInt(heightProp.value, 10); - if (isNaN(img.originalHeight)) { - img.originalHeight = maxHeight; - } - } - }, - - /** sets the image style which get's used in the angular markup */ - setImageStyle: function (img, width, height, rightMargin, bottomMargin) { - img.style = { width: width + "px", height: height + "px", "margin-right": rightMargin + "px", "margin-bottom": bottomMargin + "px" }; - img.thumbStyle = { - "background-image": "url('" + img.thumbnail + "')", - "background-repeat": "no-repeat", - "background-position": "center", - "background-size": Math.min(width, img.originalWidth) + "px " + Math.min(height, img.originalHeight) + "px" - }; - }, - - /** gets the image's scaled wdith based on the max row height */ - getScaledWidth: function (img, maxHeight) { - var scaled = img.originalWidth * maxHeight / img.originalHeight; - return scaled; - //round down, we don't want it too big even by half a pixel otherwise it'll drop to the next row - //return Math.floor(scaled); - }, - - /** returns the target row width taking into account how many images will be in the row and removing what the margin is */ - getTargetWidth: function (imgsPerRow, maxRowWidth, margin) { - //take into account the margin, we will have 1 less margin item than we have total images - return (maxRowWidth - ((imgsPerRow - 1) * margin)); - }, - - /** - This will determine the row/image height for the next collection of images which takes into account the - ideal image count per row. It will check if a row can be filled with this ideal count and if not - if there - are additional images available to fill the row it will keep calculating until they fit. - - It will return the calculated height and the number of images for the row. - - targetHeight = optional; - */ - getRowHeightForImages: function (imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin, targetHeight) { - - var idealImages = imgs.slice(0, idealImgPerRow); - //get the target row width without margin - var targetRowWidth = this.getTargetWidth(idealImages.length, maxRowWidth, margin); - //this gets the image with the smallest height which equals the maximum we can scale up for this image block - var maxScaleableHeight = this.getMaxScaleableHeight(idealImages, maxRowHeight); - //if the max scale height is smaller than the min display height, we'll use the min display height - targetHeight = targetHeight !== undefined ? targetHeight : Math.max(maxScaleableHeight, minDisplayHeight); - - var attemptedRowHeight = this.performGetRowHeight(idealImages, targetRowWidth, minDisplayHeight, targetHeight); - - if (attemptedRowHeight != null) { - - //if this is smaller than the min display then we need to use the min display, - // which means we'll need to remove one from the row so we can scale up to fill the row - if (attemptedRowHeight < minDisplayHeight) { - - if (idealImages.length > 1) { - - //we'll generate a new targetHeight that is halfway between the max and the current and recurse, passing in a new targetHeight - targetHeight += Math.floor((maxRowHeight - targetHeight) / 2); - return this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow - 1, margin, targetHeight); - } - else { - //this will occur when we only have one image remaining in the row but it's still going to be too wide even when - // using the minimum display height specified. In this case we're going to have to just crop the image in it's center - // using the minimum display height and the full row width - return { height: minDisplayHeight, imgCount: 1 }; - } - } - else { - //success! - return { height: attemptedRowHeight, imgCount: idealImages.length }; - } - } - - //we know the width will fit in a row, but we now need to figure out if we can fill - // the entire row in the case that we have more images remaining than the idealImgPerRow. - - if (idealImages.length === imgs.length) { - //we have no more remaining images to fill the space, so we'll just use the calc height - return { height: targetHeight, imgCount: idealImages.length }; - } - else if (idealImages.length === 1) { - //this will occur when we only have one image remaining in the row to process but it's not really going to fit ideally - // in the row. - return { height: minDisplayHeight, imgCount: 1 }; - } - else if (idealImages.length === idealImgPerRow && targetHeight < maxRowHeight) { - - //if we're already dealing with the ideal images per row and it's not quite wide enough, we can scale up a little bit so - // long as the targetHeight is currently less than the maxRowHeight. The scale up will be half-way between our current - // target height and the maxRowHeight (we won't loop forever though - if there's a difference of 5 px we'll just quit) - - while (targetHeight < maxRowHeight && (maxRowHeight - targetHeight) > 5) { - targetHeight += Math.floor((maxRowHeight - targetHeight) / 2); - attemptedRowHeight = this.performGetRowHeight(idealImages, targetRowWidth, minDisplayHeight, targetHeight); - if (attemptedRowHeight != null) { - //success! - return { height: attemptedRowHeight, imgCount: idealImages.length }; - } - } - - //Ok, we couldn't actually scale it up with the ideal row count we'll just recurse with a lesser image count. - return this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow - 1, margin); - } - else if (targetHeight === maxRowHeight) { - - //This is going to happen when: - // * We can fit a list of images in a row, but they come up too short (based on minDisplayHeight) - // * Then we'll try to remove an image, but when we try to scale to fit, the width comes up too narrow but the images are already at their - // maximum height (maxRowHeight) - // * So we're stuck, we cannot precicely fit the current list of images, so we'll render a row that will be max height but won't be wide enough - // which is better than rendering a row that is shorter than the minimum since that could be quite small. - - return { height: targetHeight, imgCount: idealImages.length }; - } - else { - - //we have additional images so we'll recurse and add 1 to the idealImgPerRow until it fits - return this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow + 1, margin); - } - - }, - - performGetRowHeight: function (idealImages, targetRowWidth, minDisplayHeight, targetHeight) { - - var currRowWidth = 0; - - for (var i = 0; i < idealImages.length; i++) { - var scaledW = this.getScaledWidth(idealImages[i], targetHeight); - currRowWidth += scaledW; - } - - if (currRowWidth > targetRowWidth) { - //get the new scaled height to fit - var newHeight = targetRowWidth * targetHeight / currRowWidth; - - return newHeight; - } - else if (idealImages.length === 1 && (currRowWidth <= targetRowWidth) && !idealImages[0].isFolder) { - //if there is only one image, then return the target height - return targetHeight; - } - else if (currRowWidth / targetRowWidth > 0.90) { - //it's close enough, it's at least 90% of the width so we'll accept it with the target height - return targetHeight; - } - else { - //if it's not successful, return null - return null; - } - }, - - /** builds an image grid row */ - buildRow: function (imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin, totalRemaining) { - var currRowWidth = 0; - var row = { images: [] }; - - var imageRowHeight = this.getRowHeightForImages(imgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin); - var targetWidth = this.getTargetWidth(imageRowHeight.imgCount, maxRowWidth, margin); - - var sizes = []; - //loop through the images we know fit into the height - for (var i = 0; i < imageRowHeight.imgCount; i++) { - //get the lower width to ensure it always fits - var scaledWidth = Math.floor(this.getScaledWidth(imgs[i], imageRowHeight.height)); - - if (currRowWidth + scaledWidth <= targetWidth) { - currRowWidth += scaledWidth; - sizes.push({ - width: scaledWidth, - //ensure that the height is rounded - height: Math.round(imageRowHeight.height) - }); - row.images.push(imgs[i]); - } - else if (imageRowHeight.imgCount === 1 && row.images.length === 0) { - //the image is simply too wide, we'll crop/center it - sizes.push({ - width: maxRowWidth, - //ensure that the height is rounded - height: Math.round(imageRowHeight.height) - }); - row.images.push(imgs[i]); - } - else { - //the max width has been reached - break; - } - } - - //loop through the images for the row and apply the styles - for (var j = 0; j < row.images.length; j++) { - var bottomMargin = margin; - //make the margin 0 for the last one - if (j === (row.images.length - 1)) { - margin = 0; - } - this.setImageStyle(row.images[j], sizes[j].width, sizes[j].height, margin, bottomMargin); - } - - if (row.images.length === 1 && totalRemaining > 1) { - //if there's only one image on the row and there are more images remaining, set the container to max width - row.images[0].style.width = maxRowWidth + "px"; - } - - - return row; - }, - - /** Returns the maximum image scaling height for the current image collection */ - getMaxScaleableHeight: function (imgs, maxRowHeight) { - - var smallestHeight = _.min(imgs, function (item) { return item.originalHeight; }).originalHeight; - - //adjust the smallestHeight if it is larger than the static max row height - if (smallestHeight > maxRowHeight) { - smallestHeight = maxRowHeight; - } - return smallestHeight; - }, - - /** Creates the image grid with calculated widths/heights for images to fill the grid nicely */ - buildGrid: function (images, maxRowWidth, maxRowHeight, startingIndex, minDisplayHeight, idealImgPerRow, margin, imagesOnly) { - - var rows = []; - var imagesProcessed = 0; - - //first fill in all of the original image sizes and URLs - for (var i = startingIndex; i < images.length; i++) { - var item = images[i]; - - this.setImageData(item); - this.setOriginalSize(item, maxRowHeight); - - if (imagesOnly && !item.isFolder && !item.thumbnail) { - images.splice(i, 1); - i--; - } - } - - while ((imagesProcessed + startingIndex) < images.length) { - //get the maxHeight for the current un-processed images - var currImgs = images.slice(imagesProcessed); - - //build the row - var remaining = images.length - imagesProcessed; - var row = this.buildRow(currImgs, maxRowHeight, minDisplayHeight, maxRowWidth, idealImgPerRow, margin, remaining); - if (row.images.length > 0) { - rows.push(row); - imagesProcessed += row.images.length; - } - else { - - if (currImgs.length > 0) { - throw "Could not fill grid with all images, images remaining: " + currImgs.length; - } - - //if there was nothing processed, exit - break; - } - } - - return rows; - } - }; + }; } angular.module("umbraco.services").factory("umbPhotoFolderHelper", umbPhotoFolderHelper); @@ -428,40 +452,40 @@ angular.module("umbraco.services").factory("umbPhotoFolderHelper", umbPhotoFolde */ function umbModelMapper() { - return { + return { - /** - * @ngdoc function - * @name umbraco.services.umbModelMapper#convertToEntityBasic - * @methodOf umbraco.services.umbModelMapper - * @function - * - * @description - * Converts the source model to a basic entity model, it will throw an exception if there isn't enough data to create the model. - * @param {Object} source The source model - * @param {Number} source.id The node id of the model - * @param {String} source.name The node name - * @param {String} source.icon The models icon as a css class (.icon-doc) - * @param {Number} source.parentId The parentID, if no parent, set to -1 - * @param {path} source.path comma-separated string of ancestor IDs (-1,1234,1782,1234) - */ + /** + * @ngdoc function + * @name umbraco.services.umbModelMapper#convertToEntityBasic + * @methodOf umbraco.services.umbModelMapper + * @function + * + * @description + * Converts the source model to a basic entity model, it will throw an exception if there isn't enough data to create the model. + * @param {Object} source The source model + * @param {Number} source.id The node id of the model + * @param {String} source.name The node name + * @param {String} source.icon The models icon as a css class (.icon-doc) + * @param {Number} source.parentId The parentID, if no parent, set to -1 + * @param {path} source.path comma-separated string of ancestor IDs (-1,1234,1782,1234) + */ - /** This converts the source model to a basic entity model, it will throw an exception if there isn't enough data to create the model */ - convertToEntityBasic: function (source) { - var required = ["id", "name", "icon", "parentId", "path"]; - _.each(required, function (k) { - if (!_.has(source, k)) { - throw "The source object does not contain the property " + k; + /** This converts the source model to a basic entity model, it will throw an exception if there isn't enough data to create the model */ + convertToEntityBasic: function (source) { + var required = ["id", "name", "icon", "parentId", "path"]; + _.each(required, function (k) { + if (!_.has(source, k)) { + throw "The source object does not contain the property " + k; + } + }); + var optional = ["metaData", "key", "alias"]; + //now get the basic object + var result = _.pick(source, required.concat(optional)); + return result; } - }); - var optional = ["metaData", "key", "alias"]; - //now get the basic object - var result = _.pick(source, required.concat(optional)); - return result; - } - }; + }; } angular.module('umbraco.services').factory('umbModelMapper', umbModelMapper); @@ -476,21 +500,21 @@ angular.module('umbraco.services').factory('umbModelMapper', umbModelMapper); */ function umbSessionStorage($window) { - //gets the sessionStorage object if available, otherwise just uses a normal object - // - required for unit tests. - var storage = $window['sessionStorage'] ? $window['sessionStorage'] : {}; + //gets the sessionStorage object if available, otherwise just uses a normal object + // - required for unit tests. + var storage = $window['sessionStorage'] ? $window['sessionStorage'] : {}; - return { + return { - get: function (key) { - return angular.fromJson(storage["umb_" + key]); - }, + get: function (key) { + return angular.fromJson(storage["umb_" + key]); + }, - set: function (key, value) { - storage["umb_" + key] = angular.toJson(value); - } + set: function (key, value) { + storage["umb_" + key] = angular.toJson(value); + } - }; + }; } angular.module('umbraco.services').factory('umbSessionStorage', umbSessionStorage); @@ -503,28 +527,28 @@ angular.module('umbraco.services').factory('umbSessionStorage', umbSessionStorag * used to check for updates and display a notifcation */ function updateChecker($http, umbRequestHelper) { - return { + return { - /** - * @ngdoc function - * @name umbraco.services.updateChecker#check - * @methodOf umbraco.services.updateChecker - * @function - * - * @description - * Called to load in the legacy tree js which is required on startup if a user is logged in or - * after login, but cannot be called until they are authenticated which is why it needs to be lazy loaded. - */ - check: function () { + /** + * @ngdoc function + * @name umbraco.services.updateChecker#check + * @methodOf umbraco.services.updateChecker + * @function + * + * @description + * Called to load in the legacy tree js which is required on startup if a user is logged in or + * after login, but cannot be called until they are authenticated which is why it needs to be lazy loaded. + */ + check: function () { - return umbRequestHelper.resourcePromise( - $http.get( - umbRequestHelper.getApiUrl( - "updateCheckApiBaseUrl", - "GetCheck")), - 'Failed to retrieve update status'); - } - }; + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "updateCheckApiBaseUrl", + "GetCheck")), + 'Failed to retrieve update status'); + } + }; } angular.module('umbraco.services').factory('updateChecker', updateChecker); @@ -534,43 +558,43 @@ angular.module('umbraco.services').factory('updateChecker', updateChecker); * @description A helper object used for property editors **/ function umbPropEditorHelper() { - return { - /** - * @ngdoc function - * @name getImagePropertyValue - * @methodOf umbraco.services.umbPropertyEditorHelper - * @function - * - * @description - * Returns the correct view path for a property editor, it will detect if it is a full virtual path but if not then default to the internal umbraco one - * - * @param {string} input the view path currently stored for the property editor - */ - getViewPath: function (input, isPreValue) { - var path = String(input); + return { + /** + * @ngdoc function + * @name getImagePropertyValue + * @methodOf umbraco.services.umbPropertyEditorHelper + * @function + * + * @description + * Returns the correct view path for a property editor, it will detect if it is a full virtual path but if not then default to the internal umbraco one + * + * @param {string} input the view path currently stored for the property editor + */ + getViewPath: function (input, isPreValue) { + var path = String(input); - if (path.startsWith('/')) { + if (path.startsWith('/')) { - //This is an absolute path, so just leave it - return path; - } else { + //This is an absolute path, so just leave it + return path; + } else { - if (path.indexOf("/") >= 0) { - //This is a relative path, so just leave it - return path; - } else { - if (!isPreValue) { - //i.e. views/propertyeditors/fileupload/fileupload.html - return "views/propertyeditors/" + path + "/" + path + ".html"; - } else { - //i.e. views/prevalueeditors/requiredfield.html - return "views/prevalueeditors/" + path + ".html"; - } + if (path.indexOf("/") >= 0) { + //This is a relative path, so just leave it + return path; + } else { + if (!isPreValue) { + //i.e. views/propertyeditors/fileupload/fileupload.html + return "views/propertyeditors/" + path + "/" + path + ".html"; + } else { + //i.e. views/prevalueeditors/requiredfield.html + return "views/prevalueeditors/" + path + ".html"; + } + } + + } } - - } - } - }; + }; } angular.module('umbraco.services').factory('umbPropEditorHelper', umbPropEditorHelper); @@ -581,24 +605,24 @@ angular.module('umbraco.services').factory('umbPropEditorHelper', umbPropEditorH **/ function queryStrings($window) { - var pl = /\+/g; // Regex for replacing addition symbol with a space - var search = /([^&=]+)=?([^&]*)/g; - var decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); }; + var pl = /\+/g; // Regex for replacing addition symbol with a space + var search = /([^&=]+)=?([^&]*)/g; + var decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); }; - return { - - getParams: function () { - var match; - var query = $window.location.search.substring(1); + return { - var urlParams = {}; - while (match = search.exec(query)) { - urlParams[decode(match[1])] = decode(match[2]); - } + getParams: function () { + var match; + var query = $window.location.search.substring(1); - return urlParams; - } - }; + var urlParams = {}; + while (match = search.exec(query)) { + urlParams[decode(match[1])] = decode(match[2]); + } + + return urlParams; + } + }; } angular.module('umbraco.services').factory('queryStrings', queryStrings); diff --git a/src/Umbraco.Web.UI.Client/src/controllers/main.controller.js b/src/Umbraco.Web.UI.Client/src/controllers/main.controller.js index dc39678a2f..b4536f0e35 100644 --- a/src/Umbraco.Web.UI.Client/src/controllers/main.controller.js +++ b/src/Umbraco.Web.UI.Client/src/controllers/main.controller.js @@ -8,7 +8,7 @@ * The main application controller * */ -function MainController($scope, $rootScope, $location, $routeParams, $timeout, $http, $log, appState, treeService, notificationsService, userService, navigationService, historyService, updateChecker, assetsService, eventsService, umbRequestHelper, tmhDynamicLocale, localStorageService) { +function MainController($scope, $rootScope, $location, $routeParams, $timeout, $http, $log, appState, treeService, notificationsService, userService, navigationService, historyService, updateChecker, assetsService, eventsService, umbRequestHelper, tmhDynamicLocale, localStorageService, tourService) { //the null is important because we do an explicit bool check on this in the view //the avatar is by default the umbraco logo @@ -136,6 +136,40 @@ function MainController($scope, $rootScope, $location, $routeParams, $timeout, $ }; })); + // manage the help dialog by subscribing to the showHelp appState + $scope.drawer = {}; + evts.push(eventsService.on("appState.drawerState.changed", function (e, args) { + // set view + if (args.key === "view") { + $scope.drawer.view = args.value; + } + // set custom model + if (args.key === "model") { + $scope.drawer.model = args.value; + } + // show / hide drawer + if (args.key === "showDrawer") { + $scope.drawer.show = args.value; + } + })); + + evts.push(eventsService.on("appState.tour.start", function (name, args) { + $scope.tour = args; + $scope.tour.show = true; + })); + + evts.push(eventsService.on("appState.tour.end", function () { + $scope.tour = null; + })); + + evts.push(eventsService.on("appState.tour.complete", function () { + $scope.tour = null; + })); + + evts.push(eventsService.on("appState.backdrop", function (name, args) { + $scope.backdrop = args; + })); + //ensure to unregister from all events! $scope.$on('$destroy', function () { for (var e in evts) { diff --git a/src/Umbraco.Web.UI.Client/src/controllers/search.controller.js b/src/Umbraco.Web.UI.Client/src/controllers/search.controller.js index 57ddb6babd..fe1148d6a8 100644 --- a/src/Umbraco.Web.UI.Client/src/controllers/search.controller.js +++ b/src/Umbraco.Web.UI.Client/src/controllers/search.controller.js @@ -14,7 +14,6 @@ function SearchController($scope, searchService, $log, $location, navigationServ $scope.isSearching = false; $scope.selectedResult = -1; - $scope.navigateResults = function (ev) { //38: up 40: down, 13: enter @@ -34,24 +33,37 @@ function SearchController($scope, searchService, $log, $location, navigationServ } }; - var group = undefined; + var groupNames = []; var groupIndex = -1; var itemIndex = -1; $scope.selectedItem = undefined; - + $scope.clearSearch = function () { + $scope.searchTerm = null; + }; function iterateResults(up) { //default group if (!group) { - group = $scope.groups[0]; + + for (var g in $scope.groups) { + if ($scope.groups.hasOwnProperty(g)) { + groupNames.push(g); + + } + } + + //Sorting to match the groups order + groupNames.sort(); + + group = $scope.groups[groupNames[0]]; groupIndex = 0; } if (up) { if (itemIndex === 0) { if (groupIndex === 0) { - gotoGroup($scope.groups.length - 1, true); + gotoGroup(Object.keys($scope.groups).length - 1, true); } else { gotoGroup(groupIndex - 1, true); } @@ -62,7 +74,7 @@ function SearchController($scope, searchService, $log, $location, navigationServ if (itemIndex < group.results.length - 1) { gotoItem(itemIndex + 1); } else { - if (groupIndex === $scope.groups.length - 1) { + if (groupIndex === Object.keys($scope.groups).length - 1) { gotoGroup(0); } else { gotoGroup(groupIndex + 1); @@ -73,7 +85,7 @@ function SearchController($scope, searchService, $log, $location, navigationServ function gotoGroup(index, up) { groupIndex = index; - group = $scope.groups[groupIndex]; + group = $scope.groups[groupNames[groupIndex]]; if (up) { gotoItem(group.results.length - 1); @@ -95,6 +107,13 @@ function SearchController($scope, searchService, $log, $location, navigationServ $scope.hasResults = false; if ($scope.searchTerm) { if (newVal !== null && newVal !== undefined && newVal !== oldVal) { + + //Resetting for brand new search + group = undefined; + groupNames = []; + groupIndex = -1; + itemIndex = -1; + $scope.isSearching = true; navigationService.showSearch(); $scope.selectedItem = undefined; @@ -114,7 +133,7 @@ function SearchController($scope, searchService, $log, $location, navigationServ var filtered = {}; _.each(result, function (value, key) { if (value.results.length > 0) { - filtered[key] = value; + filtered[key] = value; } }); $scope.groups = filtered; diff --git a/src/Umbraco.Web.UI.Client/src/init.js b/src/Umbraco.Web.UI.Client/src/init.js index 631e38657b..e86fa25c42 100644 --- a/src/Umbraco.Web.UI.Client/src/init.js +++ b/src/Umbraco.Web.UI.Client/src/init.js @@ -1,7 +1,7 @@ /** Executed when the application starts, binds to events and set global state */ -app.run(['userService', '$log', '$rootScope', '$location', 'queryStrings', 'navigationService', 'appState', 'editorState', 'fileManager', 'assetsService', 'eventsService', '$cookies', '$templateCache', 'localStorageService', - function (userService, $log, $rootScope, $location, queryStrings, navigationService, appState, editorState, fileManager, assetsService, eventsService, $cookies, $templateCache, localStorageService) { - +app.run(['userService', '$log', '$rootScope', '$location', 'queryStrings', 'navigationService', 'appState', 'editorState', 'fileManager', 'assetsService', 'eventsService', '$cookies', '$templateCache', 'localStorageService', 'tourService', 'dashboardResource', + function (userService, $log, $rootScope, $location, queryStrings, navigationService, appState, editorState, fileManager, assetsService, eventsService, $cookies, $templateCache, localStorageService, tourService, dashboardResource) { + //This sets the default jquery ajax headers to include our csrf token, we // need to user the beforeSend method because our token changes per user/login so // it cannot be static @@ -18,14 +18,34 @@ app.run(['userService', '$log', '$rootScope', '$location', 'queryStrings', 'navi eventsService.on("app.authenticated", function(evt, data) { assetsService._loadInitAssets().then(function() { - appState.setGlobalState("isReady", true); + + //Register all of the tours on the server + tourService.registerAllTours().then(function () { + appReady(data); + + // Auto start intro tour + tourService.getTourByAlias("umbIntroIntroduction").then(function (introTour) { + // start intro tour if it hasn't been completed or disabled + if (introTour && introTour.disabled !== true && introTour.completed !== true) { + tourService.startTour(introTour); + } + }); + + }, function(){ + appReady(data); + }); - //send the ready event with the included returnToPath,returnToSearch data - eventsService.emit("app.ready", data); - returnToPath = null, returnToSearch = null; }); + }); + function appReady(data) { + appState.setGlobalState("isReady", true); + //send the ready event with the included returnToPath,returnToSearch data + eventsService.emit("app.ready", data); + returnToPath = null, returnToSearch = null; + } + /** execute code on each successful route */ $rootScope.$on('$routeChangeSuccess', function(event, current, previous) { @@ -96,4 +116,5 @@ app.run(['userService', '$log', '$rootScope', '$location', 'queryStrings', 'navi //var touchDevice = ("ontouchstart" in window || window.touch || window.navigator.msMaxTouchPoints === 5 || window.DocumentTouch && document instanceof DocumentTouch); var touchDevice = /android|webos|iphone|ipad|ipod|blackberry|iemobile|touch/i.test(navigator.userAgent.toLowerCase()); appState.setGlobalState("touchDevice", touchDevice); + }]); diff --git a/src/Umbraco.Web.UI.Client/src/installer/installer.service.js b/src/Umbraco.Web.UI.Client/src/installer/installer.service.js index 54b4733529..7d586cae82 100644 --- a/src/Umbraco.Web.UI.Client/src/installer/installer.service.js +++ b/src/Umbraco.Web.UI.Client/src/installer/installer.service.js @@ -17,7 +17,7 @@ angular.module("umbraco.install").factory('installerService', function($rootScop //add to umbraco installer facts here var facts = ['Umbraco helped millions of people watch a man jump from the edge of space', - 'Over 420 000 websites are currently powered by Umbraco', + 'Over 440 000 websites are currently powered by Umbraco', "At least 2 people have named their cat 'Umbraco'", 'On an average day, more than 1000 people download Umbraco', 'umbraco.tv is the premier source of Umbraco video tutorials to get you started', @@ -31,10 +31,10 @@ angular.module("umbraco.install").factory('installerService', function($rootScop "At least 4 people have the Umbraco logo tattooed on them", "'Umbraco' is the danish name for an allen key", "Umbraco has been around since 2005, that's a looong time in IT", - "More than 550 people from all over the world meet each year in Denmark in June for our annual conference CodeGarden", + "More than 600 people from all over the world meet each year in Denmark in June for our annual conference CodeGarden", "While you are installing Umbraco someone else on the other side of the planet is probably doing it too", "You can extend Umbraco without modifying the source code using either JavaScript or C#", - "Umbraco was installed in more than 165 countries in 2015" + "Umbraco has been installed in more than 198 countries" ]; /** diff --git a/src/Umbraco.Web.UI.Client/src/installer/steps/user.controller.js b/src/Umbraco.Web.UI.Client/src/installer/steps/user.controller.js index 297db6ac4a..10c0d596eb 100644 --- a/src/Umbraco.Web.UI.Client/src/installer/steps/user.controller.js +++ b/src/Umbraco.Web.UI.Client/src/installer/steps/user.controller.js @@ -1,7 +1,7 @@ angular.module("umbraco.install").controller("Umbraco.Install.UserController", function($scope, installerService) { $scope.passwordPattern = /.*/; - $scope.installer.current.model.subscribeToNewsLetter = true; + $scope.installer.current.model.subscribeToNewsLetter = false; if ($scope.installer.current.model.minNonAlphaNumericLength > 0) { var exp = ""; diff --git a/src/Umbraco.Web.UI.Client/src/installer/steps/user.html b/src/Umbraco.Web.UI.Client/src/installer/steps/user.html index 8c0989457d..8fbe4263e8 100644 --- a/src/Umbraco.Web.UI.Client/src/installer/steps/user.html +++ b/src/Umbraco.Web.UI.Client/src/installer/steps/user.html @@ -18,7 +18,7 @@
      - + Your email will be used as your login
      diff --git a/src/Umbraco.Web.UI.Client/src/less/application/grid.less b/src/Umbraco.Web.UI.Client/src/less/application/grid.less index f6879bb679..2671adba32 100644 --- a/src/Umbraco.Web.UI.Client/src/less/application/grid.less +++ b/src/Umbraco.Web.UI.Client/src/less/application/grid.less @@ -40,11 +40,15 @@ body { } #mainwrapper { - height: 100%; - width: 100%; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; margin: 0; } +body.umb-drawer-is-visible #mainwrapper{ + left: @drawerWidth; +} + #contentwrapper, #contentcolumn { position: absolute; top: 0px; bottom: 0px; right: 0px; left: 80px; @@ -83,7 +87,8 @@ body { top: 0; bottom: 0; position: absolute; - text-align: center + text-align: center; + box-shadow: -10px 0px 25px rgba(0, 0, 0, 0.3) } #applications-tray { diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index 44365b95e7..d2a80d93aa 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -81,6 +81,10 @@ @import "forms/umb-validation-label.less"; // Umbraco Components +@import "components/application/umb-tour.less"; +@import "components/application/umb-backdrop.less"; +@import "components/application/umb-drawer.less"; + @import "components/editor.less"; @import "components/overlays.less"; @import "components/card.less"; @@ -125,6 +129,8 @@ @import "components/umb-checkmark.less"; @import "components/umb-list.less"; @import "components/umb-box.less"; +@import "components/umb-number-badge.less"; +@import "components/umb-progress-circle.less"; @import "components/buttons/umb-button.less"; @import "components/buttons/umb-button-group.less"; @@ -167,3 +173,6 @@ @import "hacks.less"; @import "healthcheck.less"; + +// cleanup properties.less when it is done +@import "properties.less"; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/buttons.less b/src/Umbraco.Web.UI.Client/src/less/buttons.less index e76ffc682c..1eaf285119 100644 --- a/src/Umbraco.Web.UI.Client/src/less/buttons.less +++ b/src/Umbraco.Web.UI.Client/src/less/buttons.less @@ -261,6 +261,9 @@ input[type="submit"].btn { *padding-top: 1px; *padding-bottom: 1px; } + + // Safari defaults to 1px for input. Ref U4-7721. + margin: 0px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-backdrop.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-backdrop.less new file mode 100644 index 0000000000..45e73df6da --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-backdrop.less @@ -0,0 +1,30 @@ +.umb-backdrop { + height: 100%; + width: 100%; + position: fixed; + z-index: 9999; + top: 0; + left: 0; + pointer-events: none; +} + +.umb-backdrop__backdrop { + height: 100%; + width: 100%; +} + +.umb-backdrop__rect { + position: absolute; + pointer-events: all; + margin: 0; + width: 100%; + height: 100%; + background: @black; + opacity: 0.4; + transition: 200ms opacity ease-in-out; +} + +.umb-backdrop__highlight-prevent-click { + position: absolute; + pointer-events: all; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less new file mode 100644 index 0000000000..0fae291e72 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less @@ -0,0 +1,194 @@ +.umb-drawer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 10; + width: @drawerWidth; + box-shadow: 0 0 20px rgba(0,0,0,0.19), 0 0 6px rgba(0,0,0,0.23); + background: @gray-9; +} + +.umb-drawer-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* Header */ + +.umb-drawer-header { + flex: 0 0 100px; + padding: 20px 30px; + box-sizing: border-box; +} + +.umb-drawer-header__title { + font-size: @fontSizeLarge; + font-weight: bold; + margin-top: 7px; + margin-bottom: 7px; +} + +.umb-drawer-header__subtitle { + font-size: @fontSizeSmall; +} + +/* Content */ + +.umb-drawer-content { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + padding: 0 30px 20px 30px; +} + +/* Footer */ +.umb-drawer-footer { + flex-grow: 0; + flex-shrink: 0; + flex-basis: 31px; + padding: 15px 30px; +} + +/* Our badge - should be moved */ + +.umb-help-badge { + padding: 10px 20px 10px 35px; + background: @white; + position: relative; + overflow: hidden; + border-radius: 3px; + display: block; +} + +.umb-help-badge:hover, +.umb-help-badge:active, +.umb-help-badge:focus { + text-decoration: none; + + .umb-help-badge__title { + text-decoration: underline !important; + } +} + +.umb-help-badge__icon { + font-size: 40px; + transform: translate(0,-50%); + position: absolute; + left: -15px; + top: 50%; + color: @red-l3; +} + +.umb-help-badge__title { + font-size: 15px; + font-weight: bold; + color: @black; +} + +/* Help article */ + +.umb-help-article { + background: @white; + padding: 20px; + line-height: 1.4em; +} + +/* Make sure typography looks good */ +.umb-help-article h1, +.umb-help-article h2, +.umb-help-article h3, +.umb-help-article h4 { + line-height: 1.3em; + font-weight: bold; +} + +.umb-help-article h1 { font-size: 20px; } +.umb-help-article h2 { font-size: 16px; margin-top: 20px; } +.umb-help-article h3 { font-size: 15px; } +.umb-help-article h4 { font-size: 14px; } + +.umb-help-article ol li, +.umb-help-article ul li { + line-height: 1.4em; + margin-bottom: 8px; +} + +.umb-help-article code { + white-space: pre-wrap; + word-break: break-word; +} + +.umb-help-article-navigation { + margin-top: 25px; + display: flex; + justify-content: space-between; + align-items: center; +} + + +/* Help list */ + +.umb-help-list { + list-style: none; + margin-left: 0; + margin-bottom: 0; + background: @white; + border-radius: 3px; +} + +.umb-help-list:last-child { + border-bottom: none; +} + +.umb-help-list-item { + margin-bottom: 1px; + border-radius: 0; + border-bottom: 1px solid @gray-9; +} + +.umb-help-list-item > a, +.umb-help-list-item__content { + display: flex; + align-items: center; + padding: 10px 20px; +} + +.umb-help-list-item > a:hover, +.umb-help-list-item > a:focus, +.umb-help-list-item > a:active { + text-decoration: none; + + .umb-help-list-item__title { + text-decoration: underline !important; + } +} + +.umb-help-list-item__title { + font-size: 14px; + display: block; +} + +.umb-help-list-item__description { + margin-top: 5px; + display: block; + font-size: 14px; +} + +.umb-help-list-item__icon { + margin-right: 8px; + color: @gray-4; + font-size: 18px; +} + +.umb-help-list-item__open-icon { + font-size: 14px; + color: @gray-6; + margin-left: auto; +} + +.umb-help-list-item:hover .umb-help-list-item__group-title { + text-decoration: underline; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less new file mode 100644 index 0000000000..a97c700935 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less @@ -0,0 +1,102 @@ +.umb-tour__loader { + background: @white; + z-index: 10000; + position: fixed; + height: 5px; +} + +.umb-tour__pulse { + position: fixed; + z-index: 10000; + display: none; + background: transparent; + box-shadow: 0 0 0 @green inset; + animation: pulse 2s infinite; + pointer-events: none; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 @green inset; + } + 70% { + box-shadow: 0 0 0 5px fade(@green, 80%) inset; + } + 100% { + box-shadow: 0 0 0 0 @green inset; + } +} + +.umb-tour__popover { + position: fixed; + background: @white; + border-radius: @baseBorderRadius; + z-index: 10000; + width: 320px; + max-width: 100%; + box-sizing: border-box; + padding: 15px; + + h1, h2, h3, h4, h5 { + font-weight: bold; + color: @black; + } +} + +.umb-tour__popover--l { + padding: 30px; + width: 500px; + + .umb-tour-step__header { + margin-bottom: 30px; + margin-top: 10px; + } + + .umb-tour-step__title { + font-size: 20px; + } + + .umb-tour-step__content { + margin-bottom: 25px; + font-size: 15px; + } +} + +.umb-tour-step__counter { + font-size: 13px; + color: @gray-5; +} + +.umb-tour-step__close { + position: absolute; + top: 15px; + right: 15px; + font-size: 19px; + color: @gray-7; + cursor: pointer; +} + +.umb-tour-step__close:hover, +.umb-tour-step__close:active { + color: @gray-4; + text-decoration: none; +} + +.umb-tour-step__header { + margin-bottom: 10px; + margin-top: 10px; +} + +.umb-tour-step__title { + font-weight: bold; + color: @black; + font-size: 15px; + line-height: 1.3em; + width: calc(~"100% - 35px"); +} + +.umb-tour-step__content { + margin-bottom: 15px; + font-size: 14px; + line-height: 1.6em; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less index 1bd896022c..9762eaf058 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less @@ -1,6 +1,7 @@ .umb-button-group__toggle { padding-left: 8px; padding-right: 8px; + float: none; } .umb-button-group__sub-buttons.-align-right { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less index 82e3afbb83..ab8b6b0671 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less @@ -1,7 +1,6 @@ .umb-button { position: relative; - overflow: hidden; - display: inline; + display: inline-block; } .umb-button__button:focus { @@ -100,6 +99,11 @@ } /* Sizes */ +.umb-button--xxs { + padding: 2px 10px; + font-size: 13px; +} + .umb-button--xs { padding: 5px 16px; font-size: 14px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/overlays.less b/src/Umbraco.Web.UI.Client/src/less/components/overlays.less index 68d3b20cab..0906b513a6 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/overlays.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/overlays.less @@ -2,7 +2,7 @@ position: fixed; overflow: hidden; background: @white; - z-index: 7500; + z-index: @zindexUmbOverlay; animation: fadeIn 0.2s; box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); } @@ -136,6 +136,11 @@ margin-left: 81px; } +// push left overlay when drawer is open +.umb-drawer-is-visible .umb-overlay.umb-overlay-left { + left: @drawerWidth; +} + .umb-overlay.umb-overlay-left .umb-overlay-header { flex-basis: 100px; padding: 20px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/overlays/umb-overlay-backdrop.less b/src/Umbraco.Web.UI.Client/src/less/components/overlays/umb-overlay-backdrop.less index a05e9c8f95..65b3f34c2d 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/overlays/umb-overlay-backdrop.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/overlays/umb-overlay-backdrop.less @@ -3,8 +3,12 @@ width: 100%; height: 100%; background-color: rgba(255, 255, 255, 0.50); - z-index: 2000; + z-index: @zindexOverlayBackdrop; top: 0; left: 0; animation: fadeIn 0.2s; } + +.umb-drawer-is-visible .umb-overlay-backdrop { + left: @drawerWidth; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less index 2807c2d427..de746090b9 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less @@ -6,6 +6,7 @@ display: flex; align-items: center; justify-content: center; + margin: 0 auto; color: @black; font-weight: bold; font-size: 16px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-badge.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-badge.less index 522b7564c1..015aa607c9 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-badge.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-badge.less @@ -17,11 +17,16 @@ border-color: @turquoise; } -.umb-badge--seconday { +.umb-badge--secondary { background-color: @purple-washed; border-color: @purple; } +.umb-badge--gray { + background-color: @gray-10; + border-color: @gray-8; +} + .umb-badge--danger { background-color: @red-washed; border-color: @red; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less index d25744a108..47ed97fde2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less @@ -35,3 +35,10 @@ margin-right: 5px; color: @gray-7; } + +input.umb-breadcrumbs__add-ancestor { + height: 25px; + margin-top: -2px; + margin-left: 3px; + width: 100px; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation.less index b9f2731e51..9c7eb7d7b2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation.less @@ -6,9 +6,10 @@ .umb-sub-views-nav-item { text-align: center; - margin-left: 20px; + //margin-left: 20px; cursor: pointer; display: block; + padding: 5px 10px; } .umb-sub-views-nav-item:focus { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-empty-state.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-empty-state.less index a9ded358fa..9173344112 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-empty-state.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-empty-state.less @@ -6,7 +6,7 @@ } .umb-empty-state.-small { - font-size: @fontSizeSmall; + font-size: 14px; } .umb-empty-state.-large { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less index 3551d9d31a..5cc4817142 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less @@ -280,7 +280,6 @@ .umb-grid .umb-control { position: relative; display: block; - overflow: hidden; margin-left: 10px; margin-right: 10px; margin-bottom: 10px; @@ -535,7 +534,7 @@ color: @gray-3; } -.umb-grid .umb-cell-rte textarea { +.umb-grid .umb-cell-rte textarea.mceNoEditor { display: none !important; } @@ -636,9 +635,19 @@ clear: both; } -.umb-grid .mce-btn button { - padding: 8px 6px; - line-height: inherit; +.umb-grid .mce-btn { + button { + padding-top: 8px; + padding-bottom: 8px; + padding-left: 6px; + line-height: inherit; + } + + &:not(.mce-menubtn) { + button { + padding-right: 6px; + } + } } .umb-grid .mce-toolbar { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less index dfd2761643..eecbf51ec5 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less @@ -11,7 +11,6 @@ margin: 50px 0 0 0; border: 2px solid @gray-7; border-radius: 0 10px 10px 10px; - position: relative; padding: 10px 10px 5px 10px; box-sizing: border-box; } @@ -34,6 +33,7 @@ border: 1px dashed @gray-8; color: @turquoise-d1; font-weight: bold; + position: relative; } .umb-group-builder__group.-sortable { @@ -67,16 +67,18 @@ } .umb-group-builder__group-title-wrapper { - position: absolute; - left: -2px; - top: -45px; display: flex; align-items: center; + margin-left: -12px; + margin-top: -55px; } .umb-group-builder__group-title-wrapper.-placeholder { + position: absolute; left: -1px; top: -44px; + margin-left: 0; + margin-top: 0; } .umb-group-builder__group-title { @@ -424,103 +426,114 @@ input.umb-group-builder__group-title-input { .content-type-editor-dialog.edit-property-settings { - .validation-wrapper { - position: relative; - } - - .validation-label { - position: absolute; - top: 50%; - right: 0; - font-size: 12px; - color: @red; - transform: translate(0, -50%); - } - - textarea.editor-label { - border-color:transparent; - box-shadow: none; - width: 100%; - box-sizing: border-box; - margin-bottom: 0; - font-size: 16px; - font-weight: bold; - resize: none; - line-height: 1.5em; - padding-left: 0; - border: none; - &:focus { - outline: none; - box-shadow: none !important; + .validation-wrapper { + position: relative; } - } - .editor-placeholder { - border: 1px dashed @gray-8; - width: 100%; - height: 80px; - line-height: 80px; - text-align: center; - display: block; - border-radius: 5px; - color: @gray-3; - font-weight: bold; - font-size: 14px; - color: @turquoise-d1; - &:hover { - text-decoration: none; - } - } - - .editor { - margin-bottom: 10px; - .editor-icon-wrapper { - border: 1px solid @gray-8; - width: 60px; - height: 60px; - text-align: center; - line-height: 60px; - border-radius: 5px; - float: left; - margin-right: 20px; - .icon { - font-size: 26px; - } - } - .editor-details { - float: left; - margin-top: 10px; - .editor-name { - display: block; - font-weight: bold; - } - .editor-editor { - display: block; + .validation-label { + position: absolute; + top: 50%; + right: 0; font-size: 12px; - } + color: @red; + transform: translate(0, -50%); } - .editor-settings-icon { - font-size: 18px; - margin-top: 8px; - } - } - .checkbox { - margin-bottom: 20px; - } + textarea.editor-label { + border-color: transparent; + box-shadow: none; + width: 100%; + box-sizing: border-box; + margin-bottom: 0; + font-size: 16px; + font-weight: bold; + resize: none; + line-height: 1.5em; + padding-left: 0; + border: none; - .editor-description, - .editor-validation-pattern { - min-width: 100%; - min-height: 25px; - resize: none; - box-sizing: border-box; - border: none; - overflow: hidden; - } + &:focus { + outline: none; + box-shadow: none !important; + } + } - .umb-dropdown { - width: 100%; - } + .editor-placeholder { + border: 1px dashed @gray-8; + width: 100%; + height: 80px; + line-height: 80px; + text-align: center; + display: block; + border-radius: 5px; + color: @gray-3; + font-weight: bold; + font-size: 14px; + color: @turquoise-d1; + &:hover { + text-decoration: none; + } + } + + .editor { + margin-bottom: 10px; + + .editor-icon-wrapper { + border: 1px solid @gray-8; + width: 60px; + height: 60px; + text-align: center; + line-height: 60px; + border-radius: 5px; + float: left; + margin-right: 20px; + + .icon { + font-size: 26px; + } + } + + .editor-details { + float: left; + margin-top: 10px; + + .editor-name { + display: block; + font-weight: bold; + } + + .editor-editor { + display: block; + font-size: 12px; + } + } + + .editor-settings-icon { + font-size: 18px; + margin-top: 8px; + } + } + + .checkbox { + margin-bottom: 20px; + } + + .editor-description, + .editor-validation-pattern { + min-width: 100%; + min-height: 25px; + resize: none; + box-sizing: border-box; + border: none; + overflow: hidden; + } + + .umb-dropdown { + width: 100%; + } + + label.checkbox.no-indent { + width: 100%; + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-lightbox.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-lightbox.less index 6fe68ff490..dca263ea0c 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-lightbox.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-lightbox.less @@ -13,6 +13,11 @@ flex-direction: column; } +.umb-drawer-is-visible .umb-lightbox { + width: calc(~'100%' - ~'@{drawerWidth}'); + left: @drawerWidth; +} + .umb-lightbox__backdrop { position: absolute; top: 0; @@ -40,16 +45,13 @@ .umb-lightbox__images { position: relative; z-index: 1000; + max-width: calc(~'100%' - 200px); // subtract the width of the two arrow buttons } .umb-lightbox__image { background: @white; border-radius: 3px; padding: 10px; - img { - max-width: 80vw; - max-height: 80vh; - } } .umb-lightbox__control { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less index 65d34ba693..9e038bd571 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less @@ -1,5 +1,5 @@ .umb-node-preview { - padding: 7px 0; + padding: 5px 0; display: flex; max-width: 66.6%; box-sizing: border-box; @@ -8,7 +8,6 @@ .umb-node-preview:last-of-type { border-bottom: none; - margin-bottom: 7px; } .umb-node-preview--sortable { @@ -37,6 +36,7 @@ .umb-node-preview__content { flex: 1 1 auto; margin-right: 25px; + overflow: hidden; } .umb-node-preview__name { @@ -50,6 +50,13 @@ color: @gray-3; } +.umb-node-preview__name, +.umb-node-preview__description { + /*text-overflow: ellipsis; + overflow: hidden;*/ + word-wrap: break-word; +} + .umb-node-preview__actions { flex: 0 0 auto; display: flex; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-number-badge.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-number-badge.less new file mode 100644 index 0000000000..3c2dbbdf20 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-number-badge.less @@ -0,0 +1,39 @@ +.umb-number-badge { + border: 1px solid @gray-6; + width: 25px; + height: 25px; + border-radius: 50%; + box-sizing: border-box; + display: flex; + justify-content: center; + color: @black; + font-size: 15px; +} + +.umb-number-badge--xs { + width: 20px; + height: 20px; + font-size: 13px; +} + +.umb-number-badge--s { + width: 25px; + height: 25px; +} + +.umb-number-badge--m { + width: 30px; + height: 30px; +} + +.umb-number-badge--l { + width: 40px; + height: 40px; + font-size: 18px; +} + +.umb-number-badge--xl { + width: 50px; + height: 50px; + font-size: 20px; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less new file mode 100644 index 0000000000..348d7bb5db --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less @@ -0,0 +1,49 @@ +.umb-progress-circle { + position: relative; +} + +.umb-progress-circle__view-box { + position: absolute; + -webkit-transform: rotate(-90deg); + -moz-transform: rotate(-90deg); + -o-transform: rotate(-90deg); + -ms-transform: rotate(-90deg); + transform: rotate(-90deg); +} + +// circle highlight on progressbar +.umb-progress-circle__highlight { + stroke: @green; +} + +.umb-progress-circle__highlight--primary { + stroke: @turquoise; +} + +.umb-progress-circle__highlight--secondary { + stroke: @purple; +} + +.umb-progress-circle__highlight--success { + stroke: @green; +} + +.umb-progress-circle__highlight--warning { + stroke: @yellow; +} + +.umb-progress-circle__highlight--danger { + stroke: @red; +} + +// circle progressbar bg +.umb-progress-circle__bg { + stroke: @gray-8; +} + +// the text in the center +.umb-progress-circle__percentage { + font-size: 16px; + font-weight: bold; + text-align: center; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-cards.less b/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-cards.less index 40a85bec71..e79e474935 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-cards.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-cards.less @@ -11,6 +11,8 @@ flex: 0 0 100%; max-width: 100%; display: flex; + flex-wrap: wrap; + flex-direction: column; } .umb-user-card:hover, diff --git a/src/Umbraco.Web.UI.Client/src/less/dashboards.less b/src/Umbraco.Web.UI.Client/src/less/dashboards.less index 9d1f9cf39d..5fd0e25be1 100644 --- a/src/Umbraco.Web.UI.Client/src/less/dashboards.less +++ b/src/Umbraco.Web.UI.Client/src/less/dashboards.less @@ -5,21 +5,20 @@ top: -30px; padding-top: 30px; box-shadow: inset 0px -40px 30px 25px rgba(255,255,255,1); - border-radius: 0px 0px 200px 200px; - -moz-border-radius: 0px 0px 200px 200px; + -moz-border-radius: 0px 0px 200px 200px; -webkit-border-radius: 0px 0px 200px 200px; + border-radius: 0px 0px 200px 200px; small { font-size: 14px; opacity: .5; } - .umb-loader{ + .umb-loader { width: 640px; height: 4px; } - .video_player{ video { width: 100%; @@ -27,8 +26,8 @@ border: 1px solid @gray-9; border-left: none; border-bottom: none; + -moz-box-sizing:border-box; box-sizing:border-box; - -moz-box-sizing:border-box; } input[type="range"] { @@ -67,8 +66,8 @@ .progress-bar { display: block; - box-sizing: border-box; - -moz-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing: border-box; max-width: 100%; width: 200px; height: 100%; @@ -112,26 +111,25 @@ line-height: 80px; text-align: center; position: relative; + display: flex; + justify-content: center; - i, h3 { + .icon, h3 { display: inline-block; } - i { - position: absolute; - top: 50%; - margin-top: -40px; + .icon { + font-size: 80px; } h3 { - margin: 0; + margin: 0 0 0 20px; line-height: 80px; font-weight: 700; - margin-left: 100px; font-size: 36px; letter-spacing: -1px; } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/forms.less b/src/Umbraco.Web.UI.Client/src/less/forms.less index b0557954e6..1a1f3bb93e 100644 --- a/src/Umbraco.Web.UI.Client/src/less/forms.less +++ b/src/Umbraco.Web.UI.Client/src/less/forms.less @@ -775,6 +775,8 @@ legend + .control-group { *padding-left: @horizontalComponentOffset; } } + + // Remove bottom margin on block level help text since that's accounted for on .control-group .help-block { margin-bottom: 0; @@ -795,6 +797,14 @@ legend + .control-group { padding-left: @horizontalComponentOffset; } } + +// adjustments for properties tab +.form-horizontal .block-form .control-label { + display: block; + float: none; + width: 100%; +} + //make sure buttons are always on top .umb-panel-buttons .umb-btn-toolbar .btn { position: relative; diff --git a/src/Umbraco.Web.UI.Client/src/less/hacks.less b/src/Umbraco.Web.UI.Client/src/less/hacks.less index ddba4387a3..29035213a2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/hacks.less +++ b/src/Umbraco.Web.UI.Client/src/less/hacks.less @@ -193,8 +193,8 @@ pre { display: block; padding: (@baseLineHeight - 1) / 2; margin: 0 0 @baseLineHeight / 2; - #font > #family > .monospace; - font-size: @baseFontSize - 1; // 14px to 13px + font-family: @sansFontFamily; + //font-size: @baseFontSize - 1; // 14px to 13px color: @gray-2; line-height: @baseLineHeight; white-space: pre-line; // 1 diff --git a/src/Umbraco.Web.UI.Client/src/less/healthcheck.less b/src/Umbraco.Web.UI.Client/src/less/healthcheck.less index 24660396d6..9a8c55d08b 100644 --- a/src/Umbraco.Web.UI.Client/src/less/healthcheck.less +++ b/src/Umbraco.Web.UI.Client/src/less/healthcheck.less @@ -29,12 +29,12 @@ padding: 15px 10px; box-sizing: border-box; text-align: center; - border: 2px solid transparent; + border: 1px solid @gray-8; height: 100%; } .umb-healthcheck-group:hover { - border: 2px solid @turquoise; + border: 1px solid @turquoise; cursor: pointer; } diff --git a/src/Umbraco.Web.UI.Client/src/less/main.less b/src/Umbraco.Web.UI.Client/src/less/main.less index 699deb9b18..210af19355 100644 --- a/src/Umbraco.Web.UI.Client/src/less/main.less +++ b/src/Umbraco.Web.UI.Client/src/less/main.less @@ -131,7 +131,6 @@ h5.-black { /* BLOCK MODE */ .block-form .umb-control-group { border-bottom: none; - margin-bottom: 10px !important; padding-bottom: 0; } diff --git a/src/Umbraco.Web.UI.Client/src/less/modals.less b/src/Umbraco.Web.UI.Client/src/less/modals.less index 702f80a67f..d4552c3ac5 100644 --- a/src/Umbraco.Web.UI.Client/src/less/modals.less +++ b/src/Umbraco.Web.UI.Client/src/less/modals.less @@ -181,7 +181,7 @@ width: 640px !important; } .umb-modal i { - font-size: 14px; + font-size: 20px; } .umb-modal .breadcrumb { background: none; diff --git a/src/Umbraco.Web.UI.Client/src/less/navs.less b/src/Umbraco.Web.UI.Client/src/less/navs.less index da0dfc0110..c6fd3dde01 100644 --- a/src/Umbraco.Web.UI.Client/src/less/navs.less +++ b/src/Umbraco.Web.UI.Client/src/less/navs.less @@ -249,6 +249,11 @@ border-radius: 0; } +// fix dropdown with checkbox + long text in label +.dropdown-menu > li > .flex > label { + flex: 1 1 0; +} + .dropdown-menu > li > a { padding: 8px 20px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/pages/login.less b/src/Umbraco.Web.UI.Client/src/less/pages/login.less index 924f9b20bb..5443134dd2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/pages/login.less +++ b/src/Umbraco.Web.UI.Client/src/less/pages/login.less @@ -66,7 +66,7 @@ margin-bottom: auto; } -.login-overlay .form input[type="text"], +.login-overlay .form input[type="text"], .login-overlay .form input[type="password"], .login-overlay .form input[type="email"] { height: 36px; @@ -74,10 +74,6 @@ padding-right: 10px; } -.login-overlay .btn-success { - padding: 12px 24px; -} - .login-overlay .form label { font-weight: bold; } @@ -114,8 +110,44 @@ line-height: 36px; } -.login-overlay .text-error, -.login-overlay .text-info +.login-overlay .text-error, +.login-overlay .text-info { font-weight:bold; -} +} + +.password-toggle { + position: relative; + display: block; + user-select: none; + + input::-ms-clear, input::-ms-reveal { + display: none; + } + + a { + opacity: .5; + cursor: pointer; + display: inline-block; + position: absolute; + height: 1px; + width: 45px; + height: 75%; + font-size: 0; + background-repeat: no-repeat; + background-size: 50%; + background-position: center; + top: 0; + margin-left: -45px; + z-index: 1; + -webkit-tap-highlight-color: transparent; + } + + [type="text"] + a { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cpath fill='%23444' d='M29.6.4C29 0 28 0 27.4.4L21 6.8c-1.4-.5-3-.8-5-.8C9 6 3 10 0 16c1.3 2.6 3 4.8 5.4 6.5l-5 5c-.5.5-.5 1.5 0 2 .3.4.7.5 1 .5s1 0 1.2-.4l27-27C30 2 30 1 29.6.4zM13 10c1.3 0 2.4 1 2.8 2L12 15.8c-1-.4-2-1.5-2-2.8 0-1.7 1.3-3 3-3zm-9.6 6c1.2-2 2.8-3.5 4.7-4.7l.7-.2c-.4 1-.6 2-.6 3 0 1.8.6 3.4 1.6 4.7l-2 2c-1.6-1.2-3-2.7-4-4.4zM24 13.8c0-.8 0-1.7-.4-2.4l-10 10c.7.3 1.6.4 2.4.4 4.4 0 8-3.6 8-8z'/%3E%3Cpath fill='%23444' d='M26 9l-2.2 2.2c2 1.3 3.6 3 4.8 4.8-1.2 2-2.8 3.5-4.7 4.7-2.7 1.5-5.4 2.3-8 2.3-1.4 0-2.6 0-3.8-.4L10 25c2 .6 4 1 6 1 7 0 13-4 16-10-1.4-2.8-3.5-5.2-6-7z'/%3E%3C/svg%3E"); + } + + [type="password"] + a { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cpath fill='%23444' d='M16 6C9 6 3 10 0 16c3 6 9 10 16 10s13-4 16-10c-3-6-9-10-16-10zm8 5.3c1.8 1.2 3.4 2.8 4.6 4.7-1.2 2-2.8 3.5-4.7 4.7-3 1.5-6 2.3-8 2.3s-6-.8-8-2.3C6 19.5 4 18 3 16c1.5-2 3-3.5 5-4.7l.6-.2C8 12 8 13 8 14c0 4.5 3.5 8 8 8s8-3.5 8-8c0-1-.3-2-.6-2.6l.4.3zM16 13c0 1.7-1.3 3-3 3s-3-1.3-3-3 1.3-3 3-3 3 1.3 3 3z'/%3E%3C/svg%3E"); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/properties.less b/src/Umbraco.Web.UI.Client/src/less/properties.less new file mode 100644 index 0000000000..57edbd7266 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/properties.less @@ -0,0 +1,114 @@ +//----- SCHEDULED PUBLISH ------ + +.place-holder { + height: 60px; + width: 60px; + margin: 15px auto; + background-color: @gray-8; +} + +.date-wrapper { + display: flex; + justify-content: space-around; + flex-direction: row; +} + +.date-container { + text-align: center; +} + +.date-wrapper__number{ + font-size: 40px; + line-height: 50px; + color: @gray-2; + font-weight: 900; +} + +.date-container__title { + font-size: 16px; + font-weight: bold; + color: @gray-3; + margin-bottom: 5px; +} + +.date-container__date { + padding: 0 10px; +} +.date-container__date:hover { + background-color: @gray-10; + cursor: pointer; +} + +.date-wrapper__date{ + font-size: 13px; + color: @gray-6; + margin: 0; +} + +.data-wrapper__add{ + font-size: 18px; + line-height: 10px; + color: @gray-8; + font-weight: 900; + margin: 0; +} + +.date-separate { + width: 1px; + background-color: @gray-8; +} + +//------------------- HISTORY ------------------ + +.history { + position: relative; +} + +.history-line { + width: 2px; + height: 100%; + margin: 0 0 0 14px; + background-color: @gray-8; + position: absolute; + z-index: 0; +} + +.history-item { + display: flex; + align-items: center; + margin-bottom: 24px; + position: relative; + z-index: 1; +} + +.history-item__avatar { + margin-right: 7px; +} + +.history-item__date { + font-size: 13px; + color: @gray-5; +} + +.history-item__break { + display: flex; + align-items: center; + min-width: 230px; + font-size: 14px; +} + +/* RESPONSIVE */ +@media (min-width: 1101px) and (max-width: 1365px), (max-width: 979px) { + + .history-item { + display: block; + } + + .history-item__break { + padding: 7px 0; + } + + .history-line { + display: none; + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/property-editors.less b/src/Umbraco.Web.UI.Client/src/less/property-editors.less index 2388aae2b2..2d317fa4a0 100644 --- a/src/Umbraco.Web.UI.Client/src/less/property-editors.less +++ b/src/Umbraco.Web.UI.Client/src/less/property-editors.less @@ -40,6 +40,16 @@ padding: 10px; } +.umb-contentpicker__min-max-help { + font-size: 13px; + margin-top: 5px; + color: @gray-4; +} + +.show-validation .umb-contentpicker__min-max-help { + display: none; +} + .umb-contentpicker small { &:not(:last-child) { @@ -109,16 +119,48 @@ ul.color-picker li a { } /* pre-value editor */ +/*.control-group.color-picker-preval:before { + content: ""; + display: inline-block; + vertical-align: middle; + height: 100%; +}*/ + +/*.control-group.color-picker-preval div.thumbnail { + display: inline-block; + vertical-align: middle; +}*/ +.control-group.color-picker-preval div.color-picker-prediv { + display: inline-block; + width: 60%; +} .control-group.color-picker-preval pre { display: inline; margin-right: 20px; margin-left: 10px; + width: 50%; + white-space: nowrap; + overflow: hidden; + margin-bottom: 0; + vertical-align: middle; +} + +.control-group.color-picker-preval btn { + //vertical-align: middle; +} + +.control-group.color-picker-preval input[type="text"] { + min-width: 40%; + width: 40%; + display: inline-block; + margin-right: 20px; + margin-top: 1px; } .control-group.color-picker-preval label { - border:solid @white 1px; - padding:6px; + border: solid @white 1px; + padding: 6px; } @@ -126,21 +168,21 @@ ul.color-picker li a { // Media picker // -------------------------------------------------- .umb-mediapicker .add-link { - display: inline-block; - height: 120px; - width: 120px; - text-align: center; - color: @gray-8; - border: 2px @gray-8 dashed; - line-height: 120px; - text-decoration: none; + display: flex; + justify-content:center; + align-items:center; + width: 120px; + text-align: center; + color: @gray-8; + border: 2px @gray-8 dashed; + text-decoration: none; - transition: all 150ms ease-in-out; + transition: all 150ms ease-in-out; - &:hover { - color: @turquoise-d1; - border-color: @turquoise; - } + &:hover { + color: @turquoise-d1; + border-color: @turquoise; + } } .umb-mediapicker .picked-image { @@ -165,13 +207,29 @@ ul.color-picker li a { text-decoration: none; } - - -.umb-thumbnails{ - position: relative; +.umb-mediapicker .add-link-square { + height: 120px; } + +.umb-thumbnails { + position: relative; + display: flex; + -ms-flex-direction: row; + -webkit-flex-direction: row; + flex-direction: row; + -ms-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + flex-wrap: wrap; + justify-content: flex-start; +} + +.umb-thumbnails > li.icon { + width: 14%; + text-align: center; +} + .umb-thumbnails i{margin: auto;} .umb-thumbnails a{ outline: none; @@ -207,11 +265,10 @@ ul.color-picker li a { .umb-mediapicker .umb-sortable-thumbnails li { flex-direction: column; - margin: 0; + margin: 0 5px 5px 0; padding: 5px; } - .umb-sortable-thumbnails li:hover a { display: flex; justify-content: center; @@ -219,16 +276,20 @@ ul.color-picker li a { } .umb-sortable-thumbnails li img { - max-width:100%; - max-height:100%; - margin:auto; - display:block; - background-image: url(../img/checkered-background.png); + max-width:100%; + max-height:100%; + margin:auto; + display:block; + background-image: url(../img/checkered-background.png); } -.umb-sortable-thumbnails li img.noScale{ - max-width: none !important; - max-height: none !important; +.umb-sortable-thumbnails li img.trashed { + opacity:0.3; +} + +.umb-sortable-thumbnails li img.noScale { + max-width: none !important; + max-height: none !important; } .umb-sortable-thumbnails .umb-icon-holder { @@ -242,6 +303,12 @@ ul.color-picker li a { display: block; } +.umb-sortable-thumbnails .umb-sortable-thumbnails__wrapper { + width: 124px; + height: 124px; + overflow: hidden; +} + .umb-sortable-thumbnails .umb-sortable-thumbnails__actions { position: absolute; bottom: 10px; @@ -253,9 +320,15 @@ ul.color-picker li a { visibility: hidden; } +.umb-sortable-thumbnails.ui-sortable:not(.ui-sortable-disabled) { + > li:not(.unsortable) { + cursor: move; + } +} + .umb-sortable-thumbnails li:hover .umb-sortable-thumbnails__actions { - opacity: 1; - visibility: visible; + opacity: 1; + visibility: visible; } .umb-sortable-thumbnails .umb-sortable-thumbnails__action { @@ -269,6 +342,7 @@ ul.color-picker li a { justify-content: center; align-items: center; margin-left: 5px; + text-decoration: none; } .umb-sortable-thumbnails .umb-sortable-thumbnails__action.-red { @@ -285,27 +359,27 @@ ul.color-picker li a { // ------------------------------------------------- .umb-cropper{ - position: relative; + position: relative; } .umb-cropper img, .umb-cropper-gravity img{ - position: relative; - max-width: 100%; - height: auto; - top: 0; - left: 0; + position: relative; + max-width: 100%; + height: auto; + top: 0; + left: 0; } .umb-cropper img { - max-width: none; + max-width: none; } .umb-cropper .overlay, .umb-cropper-gravity .overlay { - top: 0; - left: 0; - cursor: move; - z-index: 6001; - position: absolute; + top: 0; + left: 0; + cursor: move; + z-index: @zindexCropperOverlay; + position: absolute; } .umb-cropper .viewport{ @@ -317,43 +391,43 @@ ul.color-picker li a { } .umb-cropper-gravity .viewport{ - overflow: hidden; - position: relative; - width: 100%; - height: 100%; + overflow: hidden; + position: relative; + width: 100%; + height: 100%; } .umb-cropper .viewport:after { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 5999; - -moz-opacity: .75; - opacity: .75; - filter: alpha(opacity=7); - -webkit-box-shadow: inset 0 0 0 20px white,inset 0 0 0 21px rgba(0,0,0,.1),inset 0 0 20px 21px rgba(0,0,0,.2); - -moz-box-shadow: inset 0 0 0 20px white,inset 0 0 0 21px rgba(0,0,0,.1),inset 0 0 20px 21px rgba(0,0,0,.2); - box-shadow: inset 0 0 0 20px white,inset 0 0 0 21px rgba(0,0,0,.1),inset 0 0 20px 21px rgba(0,0,0,.2); + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: @zindexCropperOverlay - 1; + -moz-opacity: .75; + opacity: .75; + filter: alpha(opacity=7); + -webkit-box-shadow: inset 0 0 0 20px white,inset 0 0 0 21px rgba(0,0,0,.1),inset 0 0 20px 21px rgba(0,0,0,.2); + -moz-box-shadow: inset 0 0 0 20px white,inset 0 0 0 21px rgba(0,0,0,.1),inset 0 0 20px 21px rgba(0,0,0,.2); + box-shadow: inset 0 0 0 20px white,inset 0 0 0 21px rgba(0,0,0,.1),inset 0 0 20px 21px rgba(0,0,0,.2); } .umb-cropper-gravity .overlay{ - width: 14px; - height: 14px; - text-align: center; - border-radius: 20px; - background: @turquoise; - border: 3px solid @white; - opacity: 0.8; + width: 14px; + height: 14px; + text-align: center; + border-radius: 20px; + background: @turquoise; + border: 3px solid @white; + opacity: 0.8; } .umb-cropper-gravity .overlay i { - font-size: 26px; - line-height: 26px; - opacity: 0.8 !important; + font-size: 26px; + line-height: 26px; + opacity: 0.8 !important; } .umb-cropper .crop-container { @@ -361,16 +435,16 @@ ul.color-picker li a { } .umb-cropper .crop-slider { - padding: 10px; - border-top: 1px solid @gray-10; - margin-top: 10px; - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - @media (min-width: 769px) { + padding: 10px; + border-top: 1px solid @gray-10; + margin-top: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + @media (min-width: 769px) { padding: 10px 50px 10px 50px; - } + } } .umb-cropper .crop-slider i { @@ -438,6 +512,7 @@ ul.color-picker li a { top: 3px; right: 3px; cursor: pointer; + z-index: 1; } .umb-close-cropper:hover { diff --git a/src/Umbraco.Web.UI.Client/src/less/sections.less b/src/Umbraco.Web.UI.Client/src/less/sections.less index 03d25a78f5..b4e4e2ffbc 100644 --- a/src/Umbraco.Web.UI.Client/src/less/sections.less +++ b/src/Umbraco.Web.UI.Client/src/less/sections.less @@ -75,15 +75,11 @@ ul.sections li.avatar { height: 75px; padding: 22px 0 2px 0; text-align: center; - margin: 0 0 0 -4px; border-bottom: 1px solid @purple-d1; } ul.sections li.avatar a { - margin: 0 auto; padding: 0; - width: 40px; - height: 40px; border: none } diff --git a/src/Umbraco.Web.UI.Client/src/less/tree.less b/src/Umbraco.Web.UI.Client/src/less/tree.less index c859fae991..c9ab44ea21 100644 --- a/src/Umbraco.Web.UI.Client/src/less/tree.less +++ b/src/Umbraco.Web.UI.Client/src/less/tree.less @@ -119,6 +119,7 @@ .umb-tree div > a.umb-options { visibility: hidden; + flex: 1 0 auto; } .umb-tree div:hover > a.umb-options { visibility: visible; @@ -156,8 +157,8 @@ .umb-tree li > div a:not(.umb-options) { padding: 6px 0; - width: 100%; display: flex; + flex: 1 1 100%; } .umb-tree li > div:hover a:not(.umb-options) { @@ -239,6 +240,7 @@ .umb-tree .umb-tree-node-checked i:before { /*check box*/ content: "\e165" !important; + font-family: inherit; } a.umb-options { diff --git a/src/Umbraco.Web.UI.Client/src/less/variables.less b/src/Umbraco.Web.UI.Client/src/less/variables.less index 635d75d895..d72a9085ec 100644 --- a/src/Umbraco.Web.UI.Client/src/less/variables.less +++ b/src/Umbraco.Web.UI.Client/src/less/variables.less @@ -100,6 +100,27 @@ .color-green, .color-green i{color: @green-d1 !important;} .color-yellow, .color-yellow i{color: @yellow-d1 !important;} +/* Colors based on http://zavoloklom.github.io/material-design-color-palette/colors.html */ +.color-black, .color-black i { color: #000 !important; } +.color-blue-grey, .color-blue-grey i { color: #607d8b !important; } +.color-grey, .color-grey i { color: #9e9e9e !important; } +.color-brown, .color-brown i { color: #795548 !important; } +.color-blue, .color-blue i { color: #2196f3 !important; } +.color-light-blue, .color-light-blue i {color: #03a9f4 !important; } +.color-cyan, .color-cyan i { color: #00bcd4 !important; } +.color-green, .color-green i { color: #4caf50 !important; } +.color-light-green, .color-light-green i {color: #8bc34a !important; } +.color-lime, .color-lime i { color: #cddc39 !important; } +.color-yellow, .color-yellow i { color: #ffeb3b !important; } +.color-amber, .color-amber i { color: #ffc107 !important; } +.color-orange, .color-orange i { color: #ff9800 !important; } +.color-deep-orange, .color-deep-orange i { color: #ff5722 !important; } +.color-red, .color-red i { color: #f44336 !important; } +.color-pink, .color-pink i { color: #e91e63 !important; } +.color-purple,.color-purple i { color: #9c27b0 !important; } +.color-deep-purple, .color-deep-purple i { color: #673ab7 !important; } +.color-indigo, .color-indigo i { color: #3f51b5 !important; } + // Scaffolding // ------------------------- @@ -218,6 +239,9 @@ // COMPONENT VARIABLES // -------------------------------------------------- +// Drawer +@drawerWidth: 400px; + // Z-index master list // ------------------------- @@ -230,6 +254,12 @@ @zindexModalBackdrop: 1040; @zindexModal: 1050; +@zindexUmbOverlay: 7500; +@zindexOverlayBackdrop: 2000; + +// Sticky bar has a z-index of "500", which is set from javascript in directive +// so set z-index of cropper should be lower to be behind sticky bar. +@zindexCropperOverlay: 499; // Sprite icons path // ------------------------- diff --git a/src/Umbraco.Web.UI.Client/src/routes.js b/src/Umbraco.Web.UI.Client/src/routes.js index 2fb5f9beee..3fc4d3f78e 100644 --- a/src/Umbraco.Web.UI.Client/src/routes.js +++ b/src/Umbraco.Web.UI.Client/src/routes.js @@ -31,6 +31,12 @@ app.config(function ($routeProvider) { userService.getCurrentUser({ broadcastEvent: broadcast }).then(function (user) { //is auth, check if we allow or reject if (isRequired) { + + //This checks the current section and will force a redirect to 'content' as the default + if ($route.current.params.section.toLowerCase() === "default" || $route.current.params.section.toLowerCase() === "umbraco" || $route.current.params.section === "") { + $route.current.params.section = "content"; + } + // U4-5430, Benjamin Howarth // We need to change the current route params if the user only has access to a single section // To do this we need to grab the current user's allowed sections, then reject the promise with the correct path. @@ -98,26 +104,24 @@ app.config(function ($routeProvider) { resolve: doLogout() }) .when('/:section', { + //This allows us to dynamically change the template for this route since you cannot inject services into the templateUrl method. template: "
      ", //This controller will execute for this route, then we can execute some code in order to set the template Url controller: function ($scope, $route, $routeParams, $location, sectionService) { - if ($routeParams.section.toLowerCase() === "default" || $routeParams.section.toLowerCase() === "umbraco" || $routeParams.section === "") { - $routeParams.section = "content"; - } - - //We are going to check the currently loaded sections for the user and if the section we are navigating + + //We are going to check the currently loaded sections for the user and if the section we are navigating //to has a custom route path we'll use that sectionService.getSectionsForUser().then(function(sections) { - //find the one we're requesting + //find the one we're requesting var found = _.find(sections, function(s) { return s.alias === $routeParams.section; - }) + }) if (found && found.routePath) { //there's a custom route path so redirect $location.path(found.routePath); } - else { + else { //there's no custom route path so continue as normal $routeParams.url = "dashboard.aspx?app=" + $routeParams.section; $scope.templateUrl = 'views/common/dashboard.html'; @@ -138,8 +142,8 @@ app.config(function ($routeProvider) { }) .when('/:section/:tree/:method', { templateUrl: function (rp) { - - //if there is no method registered for this then show the dashboard + + //if there is no method registered for this then show the dashboard if (!rp.method) return "views/common/dashboard.html"; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.controller.js index ec1ad6e663..7f7eed8e4c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.controller.js @@ -7,14 +7,13 @@ angular.module("umbraco") $scope.icons = icons; }); - $scope.submitClass = function (icon) { + $scope.submitClass = function(icon){ if($scope.color) { $scope.submit(icon + " " + $scope.color); } - else { - $scope.submit(icon); + else { + $scope.submit(icon); } }; - } ); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.html index 4e482c26b1..f21fdf0b06 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/iconpicker.html @@ -1,7 +1,7 @@
      -
      -
      - +
      - + + No icons were found. + +
      - +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/insertmacro.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/insertmacro.html index c71b70faf8..70889f2cd7 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/insertmacro.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/insertmacro.html @@ -1,4 +1,4 @@ -
      +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js index 18158a5ff2..331010e3ac 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js @@ -1,399 +1,412 @@ angular.module("umbraco").controller("Umbraco.Dialogs.LoginController", - function ($scope, $cookies, $location, currentUserResource, formHelper, mediaHelper, umbRequestHelper, Upload, localizationService, userService, externalLoginInfo, resetPasswordCodeInfo, $timeout, authResource, dialogService, $q) { + function ($scope, $cookies, $location, currentUserResource, formHelper, mediaHelper, umbRequestHelper, Upload, localizationService, userService, externalLoginInfo, resetPasswordCodeInfo, $timeout, authResource, dialogService, $q) { - $scope.invitedUser = null; - $scope.invitedUserPasswordModel = { - password: "", - confirmPassword: "", - buttonState: "", - passwordPolicies: null, - passwordPolicyText: "" - } - $scope.avatarFile = { - filesHolder: null, - uploadStatus: null, - uploadProgress: 0, - maxFileSize: Umbraco.Sys.ServerVariables.umbracoSettings.maxFileSize + "KB", - acceptedFileTypes: mediaHelper.formatFileTypes(Umbraco.Sys.ServerVariables.umbracoSettings.imageFileTypes), - uploaded: false - } + $scope.invitedUser = null; + $scope.invitedUserPasswordModel = { + password: "", + confirmPassword: "", + buttonState: "", + passwordPolicies: null, + passwordPolicyText: "" + } + $scope.loginStates = { + submitButton: "init" + } + $scope.avatarFile = { + filesHolder: null, + uploadStatus: null, + uploadProgress: 0, + maxFileSize: Umbraco.Sys.ServerVariables.umbracoSettings.maxFileSize + "KB", + acceptedFileTypes: mediaHelper.formatFileTypes(Umbraco.Sys.ServerVariables.umbracoSettings.imageFileTypes), + uploaded: false + } + $scope.togglePassword = function () { + var elem = $("form[name='loginForm'] input[name='password']"); + elem.attr("type", (elem.attr("type") === "text" ? "password" : "text")); + } - function init() { - // Check if it is a new user - var inviteVal = $location.search().invite; - if (inviteVal && (inviteVal === "1" || inviteVal === "2")) { + function init() { + // Check if it is a new user + var inviteVal = $location.search().invite; + if (inviteVal && (inviteVal === "1" || inviteVal === "2")) { - $q.all([ - //get the current invite user - authResource.getCurrentInvitedUser().then(function (data) { - $scope.invitedUser = data; - }, - function() { - //it failed so we should remove the search - $location.search('invite', null); - }), - //get the membership provider config for password policies - authResource.getMembershipProviderConfig().then(function (data) { - $scope.invitedUserPasswordModel.passwordPolicies = data; + $q.all([ + //get the current invite user + authResource.getCurrentInvitedUser().then(function (data) { + $scope.invitedUser = data; + }, + function () { + //it failed so we should remove the search + $location.search('invite', null); + }), + //get the membership provider config for password policies + authResource.getMembershipProviderConfig().then(function (data) { + $scope.invitedUserPasswordModel.passwordPolicies = data; - //localize the text - localizationService.localize("errorHandling_errorInPasswordFormat", - [ - $scope.invitedUserPasswordModel.passwordPolicies.minPasswordLength, - $scope.invitedUserPasswordModel.passwordPolicies.minNonAlphaNumericChars - ]).then(function(data) { - $scope.invitedUserPasswordModel.passwordPolicyText = data; + //localize the text + localizationService.localize("errorHandling_errorInPasswordFormat", + [ + $scope.invitedUserPasswordModel.passwordPolicies.minPasswordLength, + $scope.invitedUserPasswordModel.passwordPolicies.minNonAlphaNumericChars + ]).then(function (data) { + $scope.invitedUserPasswordModel.passwordPolicyText = data; + }); + }) + ]).then(function () { + + $scope.inviteStep = Number(inviteVal); + + }); + } + } + + $scope.changeAvatar = function (files, event) { + if (files && files.length > 0) { + upload(files[0]); + } + }; + + $scope.getStarted = function () { + $location.search('invite', null); + $scope.submit(true); + } + + function upload(file) { + + $scope.avatarFile.uploadProgress = 0; + + Upload.upload({ + url: umbRequestHelper.getApiUrl("currentUserApiBaseUrl", "PostSetAvatar"), + fields: {}, + file: file + }).progress(function (evt) { + + if ($scope.avatarFile.uploadStatus !== "done" && $scope.avatarFile.uploadStatus !== "error") { + // set uploading status on file + $scope.avatarFile.uploadStatus = "uploading"; + + // calculate progress in percentage + var progressPercentage = parseInt(100.0 * evt.loaded / evt.total, 10); + + // set percentage property on file + $scope.avatarFile.uploadProgress = progressPercentage; + } + + }).success(function (data, status, headers, config) { + + $scope.avatarFile.uploadProgress = 100; + + // set done status on file + $scope.avatarFile.uploadStatus = "done"; + + $scope.invitedUser.avatars = data; + + $scope.avatarFile.uploaded = true; + + }).error(function (evt, status, headers, config) { + + // set status done + $scope.avatarFile.uploadStatus = "error"; + + // If file not found, server will return a 404 and display this message + if (status === 404) { + $scope.avatarFile.serverErrorMessage = "File not found"; + } + else if (status == 400) { + //it's a validation error + $scope.avatarFile.serverErrorMessage = evt.message; + } + else { + //it's an unhandled error + //if the service returns a detailed error + if (evt.InnerException) { + $scope.avatarFile.serverErrorMessage = evt.InnerException.ExceptionMessage; + + //Check if its the common "too large file" exception + if (evt.InnerException.StackTrace && evt.InnerException.StackTrace.indexOf("ValidateRequestEntityLength") > 0) { + $scope.avatarFile.serverErrorMessage = "File too large to upload"; + } + + } else if (evt.Message) { + $scope.avatarFile.serverErrorMessage = evt.Message; + } + } }); - }) - ]).then(function () { - - $scope.inviteStep = Number(inviteVal); - - }); - } - } - - $scope.changeAvatar = function (files, event) { - if (files && files.length > 0) { - upload(files[0]); - } - }; - - $scope.getStarted = function() { - $location.search('invite', null); - $scope.submit(true); - } - - function upload(file) { - - $scope.avatarFile.uploadProgress = 0; - - Upload.upload({ - url: umbRequestHelper.getApiUrl("currentUserApiBaseUrl", "PostSetAvatar"), - fields: {}, - file: file - }).progress(function (evt) { - - if ($scope.avatarFile.uploadStatus !== "done" && $scope.avatarFile.uploadStatus !== "error") { - // set uploading status on file - $scope.avatarFile.uploadStatus = "uploading"; - - // calculate progress in percentage - var progressPercentage = parseInt(100.0 * evt.loaded / evt.total, 10); - - // set percentage property on file - $scope.avatarFile.uploadProgress = progressPercentage; } - }).success(function (data, status, headers, config) { + $scope.inviteSavePassword = function () { - $scope.avatarFile.uploadProgress = 100; + if (formHelper.submitForm({ scope: $scope, statusMessage: "Saving..." })) { - // set done status on file - $scope.avatarFile.uploadStatus = "done"; + $scope.invitedUserPasswordModel.buttonState = "busy"; - $scope.invitedUser.avatars = data; + currentUserResource.performSetInvitedUserPassword($scope.invitedUserPasswordModel.password) + .then(function (data) { - $scope.avatarFile.uploaded = true; + //success + formHelper.resetForm({ scope: $scope, notifications: data.notifications }); + $scope.invitedUserPasswordModel.buttonState = "success"; + //set the user and set them as logged in + $scope.invitedUser = data; + userService.setAuthenticationSuccessful(data); - }).error(function (evt, status, headers, config) { + $scope.inviteStep = 2; - // set status done - $scope.avatarFile.uploadStatus = "error"; + }, function (err) { - // If file not found, server will return a 404 and display this message - if (status === 404) { - $scope.avatarFile.serverErrorMessage = "File not found"; + //error + formHelper.handleError(err); + + $scope.invitedUserPasswordModel.buttonState = "error"; + + }); + } + }; + + var setFieldFocus = function (form, field) { + $timeout(function () { + $("form[name='" + form + "'] input[name='" + field + "']").focus(); + }); } - else if (status == 400) { - //it's a validation error - $scope.avatarFile.serverErrorMessage = evt.message; - } - else { - //it's an unhandled error - //if the service returns a detailed error - if (evt.InnerException) { - $scope.avatarFile.serverErrorMessage = evt.InnerException.ExceptionMessage; - //Check if its the common "too large file" exception - if (evt.InnerException.StackTrace && evt.InnerException.StackTrace.indexOf("ValidateRequestEntityLength") > 0) { - $scope.avatarFile.serverErrorMessage = "File too large to upload"; + var twoFactorloginDialog = null; + function show2FALoginDialog(view, callback) { + if (!twoFactorloginDialog) { + twoFactorloginDialog = dialogService.open({ + + //very special flag which means that global events cannot close this dialog + manualClose: true, + template: view, + modalClass: "login-overlay", + animation: "slide", + show: true, + callback: callback, + + }); + } + } + + function resetInputValidation() { + $scope.confirmPassword = ""; + $scope.password = ""; + $scope.login = ""; + if ($scope.loginForm) { + $scope.loginForm.username.$setValidity('auth', true); + $scope.loginForm.password.$setValidity('auth', true); + } + if ($scope.requestPasswordResetForm) { + $scope.requestPasswordResetForm.email.$setValidity("auth", true); + } + if ($scope.setPasswordForm) { + $scope.setPasswordForm.password.$setValidity('auth', true); + $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); + } + } + + $scope.allowPasswordReset = Umbraco.Sys.ServerVariables.umbracoSettings.allowPasswordReset; + + $scope.showLogin = function () { + $scope.errorMsg = ""; + resetInputValidation(); + $scope.view = "login"; + setFieldFocus("loginForm", "username"); + } + + $scope.showRequestPasswordReset = function () { + $scope.errorMsg = ""; + resetInputValidation(); + $scope.view = "request-password-reset"; + $scope.showEmailResetConfirmation = false; + setFieldFocus("requestPasswordResetForm", "email"); + } + + $scope.showSetPassword = function () { + $scope.errorMsg = ""; + resetInputValidation(); + $scope.view = "set-password"; + setFieldFocus("setPasswordForm", "password"); + } + + var d = new Date(); + var konamiGreetings = new Array("Suze Sunday", "Malibu Monday", "Tequila Tuesday", "Whiskey Wednesday", "Negroni Day", "Fernet Friday", "Sancerre Saturday"); + var konamiMode = $cookies.konamiLogin; + if (konamiMode == "1") { + $scope.greeting = "Happy " + konamiGreetings[d.getDay()]; + } else { + localizationService.localize("login_greeting" + d.getDay()).then(function (label) { + $scope.greeting = label; + }); // weekday[d.getDay()]; + } + $scope.errorMsg = ""; + + $scope.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl; + $scope.externalLoginProviders = externalLoginInfo.providers; + $scope.externalLoginInfo = externalLoginInfo; + $scope.resetPasswordCodeInfo = resetPasswordCodeInfo; + $scope.backgroundImage = Umbraco.Sys.ServerVariables.umbracoSettings.loginBackgroundImage; + + $scope.activateKonamiMode = function () { + if ($cookies.konamiLogin == "1") { + // somehow I can't update the cookie value using $cookies, so going native + document.cookie = "konamiLogin=; expires=Thu, 01 Jan 1970 00:00:01 GMT;"; + document.location.reload(); + } else { + document.cookie = "konamiLogin=1; expires=Tue, 01 Jan 2030 00:00:01 GMT;"; + $scope.$apply(function () { + $scope.greeting = "Happy " + konamiGreetings[d.getDay()]; + }); + } + } + + $scope.loginSubmit = function (login, password) { + + //TODO: Do validation properly like in the invite password update + + //if the login and password are not empty we need to automatically + // validate them - this is because if there are validation errors on the server + // then the user has to change both username & password to resubmit which isn't ideal, + // so if they're not empty, we'll just make sure to set them to valid. + if (login && password && login.length > 0 && password.length > 0) { + $scope.loginForm.username.$setValidity('auth', true); + $scope.loginForm.password.$setValidity('auth', true); } - } else if (evt.Message) { - $scope.avatarFile.serverErrorMessage = evt.Message; - } + if ($scope.loginForm.$invalid) { + return; + } + + $scope.loginStates.submitButton = "busy"; + + userService.authenticate(login, password) + .then(function (data) { + $scope.loginStates.submitButton = "success"; + $scope.submit(true); + }, + function (reason) { + + //is Two Factor required? + if (reason.status === 402) { + $scope.errorMsg = "Additional authentication required"; + show2FALoginDialog(reason.data.twoFactorView, $scope.submit); + } + else { + $scope.loginStates.submitButton = "error"; + $scope.errorMsg = reason.errorMsg; + + //set the form inputs to invalid + $scope.loginForm.username.$setValidity("auth", false); + $scope.loginForm.password.$setValidity("auth", false); + } + }); + + //setup a watch for both of the model values changing, if they change + // while the form is invalid, then revalidate them so that the form can + // be submitted again. + $scope.loginForm.username.$viewChangeListeners.push(function () { + if ($scope.loginForm.$invalid) { + $scope.loginForm.username.$setValidity('auth', true); + $scope.loginForm.password.$setValidity('auth', true); + } + }); + $scope.loginForm.password.$viewChangeListeners.push(function () { + if ($scope.loginForm.$invalid) { + $scope.loginForm.username.$setValidity('auth', true); + $scope.loginForm.password.$setValidity('auth', true); + } + }); + }; + + $scope.requestPasswordResetSubmit = function (email) { + + //TODO: Do validation properly like in the invite password update + + if (email && email.length > 0) { + $scope.requestPasswordResetForm.email.$setValidity('auth', true); + } + + $scope.showEmailResetConfirmation = false; + + if ($scope.requestPasswordResetForm.$invalid) { + return; + } + + $scope.errorMsg = ""; + + authResource.performRequestPasswordReset(email) + .then(function () { + //remove the email entered + $scope.email = ""; + $scope.showEmailResetConfirmation = true; + }, function (reason) { + $scope.errorMsg = reason.errorMsg; + $scope.requestPasswordResetForm.email.$setValidity("auth", false); + }); + + $scope.requestPasswordResetForm.email.$viewChangeListeners.push(function () { + if ($scope.requestPasswordResetForm.email.$invalid) { + $scope.requestPasswordResetForm.email.$setValidity('auth', true); + } + }); + }; + + $scope.setPasswordSubmit = function (password, confirmPassword) { + + $scope.showSetPasswordConfirmation = false; + + if (password && confirmPassword && password.length > 0 && confirmPassword.length > 0) { + $scope.setPasswordForm.password.$setValidity('auth', true); + $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); + } + + if ($scope.setPasswordForm.$invalid) { + return; + } + + //TODO: All of this logic can/should be shared! We should do validation the nice way instead of all of this manual stuff, see: inviteSavePassword + authResource.performSetPassword($scope.resetPasswordCodeInfo.resetCodeModel.userId, password, confirmPassword, $scope.resetPasswordCodeInfo.resetCodeModel.resetCode) + .then(function () { + $scope.showSetPasswordConfirmation = true; + $scope.resetComplete = true; + + //reset the values in the resetPasswordCodeInfo angular so if someone logs out the change password isn't shown again + resetPasswordCodeInfo.resetCodeModel = null; + + }, function (reason) { + if (reason.data && reason.data.Message) { + $scope.errorMsg = reason.data.Message; + } + else { + $scope.errorMsg = reason.errorMsg; + } + $scope.setPasswordForm.password.$setValidity("auth", false); + $scope.setPasswordForm.confirmPassword.$setValidity("auth", false); + }); + + $scope.setPasswordForm.password.$viewChangeListeners.push(function () { + if ($scope.setPasswordForm.password.$invalid) { + $scope.setPasswordForm.password.$setValidity('auth', true); + } + }); + $scope.setPasswordForm.confirmPassword.$viewChangeListeners.push(function () { + if ($scope.setPasswordForm.confirmPassword.$invalid) { + $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); + } + }); } - }); - } - $scope.inviteSavePassword = function () { - - if (formHelper.submitForm({ scope: $scope, statusMessage: "Saving..." })) { - - $scope.invitedUserPasswordModel.buttonState = "busy"; - currentUserResource.performSetInvitedUserPassword($scope.invitedUserPasswordModel.password) - .then(function (data) { + //Now, show the correct panel: - //success - formHelper.resetForm({ scope: $scope, notifications: data.notifications }); - $scope.invitedUserPasswordModel.buttonState = "success"; - //set the user and set them as logged in - $scope.invitedUser = data; - userService.setAuthenticationSuccessful(data); - - $scope.inviteStep = 2; - - }, function(err) { - - //error - formHelper.handleError(err); - - $scope.invitedUserPasswordModel.buttonState = "error"; - - }); - } - }; - - var setFieldFocus = function (form, field) { - $timeout(function () { - $("form[name='" + form + "'] input[name='" + field + "']").focus(); - }); - } - - var twoFactorloginDialog = null; - function show2FALoginDialog(view, callback) { - if (!twoFactorloginDialog) { - twoFactorloginDialog = dialogService.open({ - - //very special flag which means that global events cannot close this dialog - manualClose: true, - template: view, - modalClass: "login-overlay", - animation: "slide", - show: true, - callback: callback, - - }); - } - } - - function resetInputValidation() { - $scope.confirmPassword = ""; - $scope.password = ""; - $scope.login = ""; - if ($scope.loginForm) { - $scope.loginForm.username.$setValidity('auth', true); - $scope.loginForm.password.$setValidity('auth', true); - } - if ($scope.requestPasswordResetForm) { - $scope.requestPasswordResetForm.email.$setValidity("auth", true); - } - if ($scope.setPasswordForm) { - $scope.setPasswordForm.password.$setValidity('auth', true); - $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); - } - } - - $scope.allowPasswordReset = Umbraco.Sys.ServerVariables.umbracoSettings.allowPasswordReset; - - $scope.showLogin = function () { - $scope.errorMsg = ""; - resetInputValidation(); - $scope.view = "login"; - setFieldFocus("loginForm", "username"); - } - - $scope.showRequestPasswordReset = function () { - $scope.errorMsg = ""; - resetInputValidation(); - $scope.view = "request-password-reset"; - $scope.showEmailResetConfirmation = false; - setFieldFocus("requestPasswordResetForm", "email"); - } - - $scope.showSetPassword = function () { - $scope.errorMsg = ""; - resetInputValidation(); - $scope.view = "set-password"; - setFieldFocus("setPasswordForm", "password"); - } - - var d = new Date(); - var konamiGreetings = new Array("Suze Sunday", "Malibu Monday", "Tequila Tuesday", "Whiskey Wednesday", "Negroni Day", "Fernet Friday", "Sancerre Saturday"); - var konamiMode = $cookies.konamiLogin; - if (konamiMode == "1") { - $scope.greeting = "Happy " + konamiGreetings[d.getDay()]; - } else { - localizationService.localize("login_greeting" + d.getDay()).then(function (label) { - $scope.greeting = label; - }); // weekday[d.getDay()]; - } - $scope.errorMsg = ""; - - $scope.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl; - $scope.externalLoginProviders = externalLoginInfo.providers; - $scope.externalLoginInfo = externalLoginInfo; - $scope.resetPasswordCodeInfo = resetPasswordCodeInfo; - $scope.backgroundImage = Umbraco.Sys.ServerVariables.umbracoSettings.loginBackgroundImage; - - $scope.activateKonamiMode = function () { - if ($cookies.konamiLogin == "1") { - // somehow I can't update the cookie value using $cookies, so going native - document.cookie = "konamiLogin=; expires=Thu, 01 Jan 1970 00:00:01 GMT;"; - document.location.reload(); - } else { - document.cookie = "konamiLogin=1; expires=Tue, 01 Jan 2030 00:00:01 GMT;"; - $scope.$apply(function () { - $scope.greeting = "Happy " + konamiGreetings[d.getDay()]; - }); - } - } - - $scope.loginSubmit = function (login, password) { - - //TODO: Do validation properly like in the invite password update - - //if the login and password are not empty we need to automatically - // validate them - this is because if there are validation errors on the server - // then the user has to change both username & password to resubmit which isn't ideal, - // so if they're not empty, we'll just make sure to set them to valid. - if (login && password && login.length > 0 && password.length > 0) { - $scope.loginForm.username.$setValidity('auth', true); - $scope.loginForm.password.$setValidity('auth', true); - } - - if ($scope.loginForm.$invalid) { - return; - } - - userService.authenticate(login, password) - .then(function (data) { - $scope.submit(true); - }, - function (reason) { - - //is Two Factor required? - if (reason.status === 402) { - $scope.errorMsg = "Additional authentication required"; - show2FALoginDialog(reason.data.twoFactorView, $scope.submit); - } - else { - $scope.errorMsg = reason.errorMsg; - - //set the form inputs to invalid - $scope.loginForm.username.$setValidity("auth", false); - $scope.loginForm.password.$setValidity("auth", false); - } - }); - - //setup a watch for both of the model values changing, if they change - // while the form is invalid, then revalidate them so that the form can - // be submitted again. - $scope.loginForm.username.$viewChangeListeners.push(function () { - if ($scope.loginForm.username.$invalid) { - $scope.loginForm.username.$setValidity('auth', true); + if ($scope.resetPasswordCodeInfo.resetCodeModel) { + $scope.showSetPassword(); } - }); - $scope.loginForm.password.$viewChangeListeners.push(function () { - if ($scope.loginForm.password.$invalid) { - $scope.loginForm.password.$setValidity('auth', true); + else if ($scope.resetPasswordCodeInfo.errors.length > 0) { + $scope.view = "password-reset-code-expired"; } - }); - }; - - $scope.requestPasswordResetSubmit = function (email) { - - //TODO: Do validation properly like in the invite password update - - if (email && email.length > 0) { - $scope.requestPasswordResetForm.email.$setValidity('auth', true); - } - - $scope.showEmailResetConfirmation = false; - - if ($scope.requestPasswordResetForm.$invalid) { - return; - } - - $scope.errorMsg = ""; - - authResource.performRequestPasswordReset(email) - .then(function () { - //remove the email entered - $scope.email = ""; - $scope.showEmailResetConfirmation = true; - }, function (reason) { - $scope.errorMsg = reason.errorMsg; - $scope.requestPasswordResetForm.email.$setValidity("auth", false); - }); - - $scope.requestPasswordResetForm.email.$viewChangeListeners.push(function () { - if ($scope.requestPasswordResetForm.email.$invalid) { - $scope.requestPasswordResetForm.email.$setValidity('auth', true); + else { + $scope.showLogin(); } - }); - }; - $scope.setPasswordSubmit = function (password, confirmPassword) { + init(); - $scope.showSetPasswordConfirmation = false; - - if (password && confirmPassword && password.length > 0 && confirmPassword.length > 0) { - $scope.setPasswordForm.password.$setValidity('auth', true); - $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); - } - - if ($scope.setPasswordForm.$invalid) { - return; - } - - //TODO: All of this logic can/should be shared! We should do validation the nice way instead of all of this manual stuff, see: inviteSavePassword - authResource.performSetPassword($scope.resetPasswordCodeInfo.resetCodeModel.userId, password, confirmPassword, $scope.resetPasswordCodeInfo.resetCodeModel.resetCode) - .then(function () { - $scope.showSetPasswordConfirmation = true; - $scope.resetComplete = true; - - //reset the values in the resetPasswordCodeInfo angular so if someone logs out the change password isn't shown again - resetPasswordCodeInfo.resetCodeModel = null; - - }, function (reason) { - if (reason.data && reason.data.Message) { - $scope.errorMsg = reason.data.Message; - } - else { - $scope.errorMsg = reason.errorMsg; - } - $scope.setPasswordForm.password.$setValidity("auth", false); - $scope.setPasswordForm.confirmPassword.$setValidity("auth", false); - }); - - $scope.setPasswordForm.password.$viewChangeListeners.push(function () { - if ($scope.setPasswordForm.password.$invalid) { - $scope.setPasswordForm.password.$setValidity('auth', true); - } - }); - $scope.setPasswordForm.confirmPassword.$viewChangeListeners.push(function () { - if ($scope.setPasswordForm.confirmPassword.$invalid) { - $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); - } - }); - } - - - //Now, show the correct panel: - - if ($scope.resetPasswordCodeInfo.resetCodeModel) { - $scope.showSetPassword(); - } - else if ($scope.resetPasswordCodeInfo.errors.length > 0) { - $scope.view = "password-reset-code-expired"; - } - else { - $scope.showLogin(); - } - - init(); - - }); + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html index 61d04d30fb..3fc4698565 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html @@ -1,242 +1,249 @@ 
      -
      +
      - + - - -
    - + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/membergrouppicker.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/membergrouppicker.html index 48302cea02..e2ff9790d5 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/membergrouppicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/membergrouppicker.html @@ -1,7 +1,7 @@
    - + \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js new file mode 100644 index 0000000000..4d2b43c078 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js @@ -0,0 +1,178 @@ +(function () { + "use strict"; + + function HelpDrawerController($scope, $routeParams, $timeout, dashboardResource, localizationService, userService, eventsService, helpService, appState, tourService, $filter) { + + var vm = this; + var evts = []; + + vm.title = localizationService.localize("general_help"); + vm.subtitle = "Umbraco version" + " " + Umbraco.Sys.ServerVariables.application.version; + vm.section = $routeParams.section; + vm.tree = $routeParams.tree; + vm.sectionName = ""; + vm.customDashboard = null; + vm.tours = []; + + vm.closeDrawer = closeDrawer; + vm.startTour = startTour; + vm.getTourGroupCompletedPercentage = getTourGroupCompletedPercentage; + vm.showTourButton = showTourButton; + + function startTour(tour) { + tourService.startTour(tour); + closeDrawer(); + } + + function oninit() { + + tourService.getGroupedTours().then(function(groupedTours) { + vm.tours = groupedTours; + getTourGroupCompletedPercentage(); + }); + + // load custom help dashboard + dashboardResource.getDashboard("user-help").then(function (dashboard) { + vm.customDashboard = dashboard; + }); + + if (!vm.section) { + vm.section = "content"; + } + + setSectionName(); + + userService.getCurrentUser().then(function (user) { + + vm.userType = user.userType; + vm.userLang = user.locale; + + evts.push(eventsService.on("appState.treeState.changed", function (e, args) { + handleSectionChange(); + })); + + findHelp(vm.section, vm.tree, vm.usertype, vm.userLang); + + }); + + // check if a tour is running - if it is open the matching group + var currentTour = tourService.getCurrentTour(); + + if (currentTour) { + openTourGroup(currentTour.alias); + } + + } + + function closeDrawer() { + appState.setDrawerState("showDrawer", false); + } + + function handleSectionChange() { + $timeout(function () { + if (vm.section !== $routeParams.section || vm.tree !== $routeParams.tree) { + + vm.section = $routeParams.section; + vm.tree = $routeParams.tree; + + setSectionName(); + findHelp(vm.section, vm.tree, vm.usertype, vm.userLang); + + } + }); + } + + function findHelp(section, tree, usertype, userLang) { + + helpService.getContextHelpForPage(section, tree).then(function (topics) { + vm.topics = topics; + }); + + var rq = {}; + rq.section = vm.section; + rq.usertype = usertype; + rq.lang = userLang; + + if ($routeParams.url) { + rq.path = decodeURIComponent($routeParams.url); + + if (rq.path.indexOf(Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath) === 0) { + rq.path = rq.path.substring(Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath.length); + } + + if (rq.path.indexOf(".aspx") > 0) { + rq.path = rq.path.substring(0, rq.path.indexOf(".aspx")); + } + + } else { + rq.path = rq.section + "/" + $routeParams.tree + "/" + $routeParams.method; + } + + helpService.findVideos(rq).then(function(videos){ + vm.videos = videos; + }); + + } + + function setSectionName() { + // Get section name + var languageKey = "sections_" + vm.section; + localizationService.localize(languageKey).then(function (value) { + vm.sectionName = value; + }); + } + + function showTourButton(index, tourGroup) { + if(index !== 0) { + var prevTour = tourGroup.tours[index - 1]; + if(prevTour.completed) { + return true; + } + } else { + return true; + } + } + + function openTourGroup(tourAlias) { + angular.forEach(vm.tours, function (group) { + angular.forEach(group, function (tour) { + if (tour.alias === tourAlias) { + group.open = true; + } + }); + }); + } + + function getTourGroupCompletedPercentage() { + // Finding out, how many tours are completed for the progress circle + angular.forEach(vm.tours, function(group){ + var completedTours = 0; + angular.forEach(group.tours, function(tour){ + if(tour.completed) { + completedTours++; + } + }); + group.completedPercentage = Math.round((completedTours/group.tours.length)*100); + }); + } + + evts.push(eventsService.on("appState.tour.complete", function (event, tour) { + tourService.getGroupedTours().then(function(groupedTours) { + vm.tours = groupedTours; + openTourGroup(tour.alias); + getTourGroupCompletedPercentage(); + }); + })); + + $scope.$on('$destroy', function () { + for (var e in evts) { + eventsService.unsubscribe(evts[e]); + } + }); + + oninit(); + + } + + angular.module("umbraco").controller("Umbraco.Drawers.Help", HelpDrawerController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html new file mode 100644 index 0000000000..4829c8964a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html @@ -0,0 +1,130 @@ + + + + + + + + +
    + +
    Tours
    + +
    + +
    + + +
    + {{tourGroup.group}} + Other +
    + + +
    + +
    +
    +
    +
    +
    {{ $index + 1 }}
    + + {{ tour.name }} +
    +
    + + +
    +
    +
    +
    +
    +
    + +
    + + + +
    +
    +
    +
    +
    {{property.caption}}
    +
    +
    +
    +
    +
    + + + + + +
    +
    Videos
    + +
    + + + + +
    + + + +
    + + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/contenttypeeditor/compositions/compositions.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/contenttypeeditor/compositions/compositions.html index 3cc81fdcf6..6759da0fb4 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/contenttypeeditor/compositions/compositions.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/contenttypeeditor/compositions/compositions.html @@ -7,9 +7,10 @@ style="width: 100%" ng-model="searchTerm" class="umb-search-field search-query input-block-level" - localize="placeholder" - placeholder="@placeholders_filter" - umb-auto-focus> + localize="placeholder" + placeholder="@placeholders_filter" + umb-auto-focus + no-dirty-check />
    @@ -17,20 +18,17 @@ - - + + - - + +
    • @@ -41,7 +39,7 @@ checklist-model="model.compositeContentTypes" checklist-value="compositeContentType.contentType.alias" ng-change="model.selectCompositeContentType(compositeContentType.contentType)" - ng-disabled="compositeContentType.allowed===false || compositeContentType.inherited"/> + ng-disabled="compositeContentType.allowed===false || compositeContentType.inherited" /> - - Yes, convert line breaks + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html index d71352c3d9..aac4830d52 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html @@ -1,25 +1,25 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.controller.js index 5401ae5387..91c74311b3 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.controller.js @@ -22,6 +22,8 @@ angular.module("umbraco").controller("Umbraco.Overlays.LinkPickerController", selectedSearchResults: [] }; + $scope.showTarget = $scope.model.hideTarget !== true; + if (dialogOptions.currentTarget) { $scope.model.target = dialogOptions.currentTarget; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.html index 13bcfd9c3e..e1b13206df 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/linkpicker/linkpicker.html @@ -18,7 +18,7 @@ ng-model="model.target.name" /> - + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/macropicker/macropicker.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/macropicker/macropicker.html index 6bcbaad50c..9f9356d85e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/macropicker/macropicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/macropicker/macropicker.html @@ -13,7 +13,8 @@ class="umb-search-field search-query input-block-level" localize="placeholder" placeholder="@placeholders_filter" - umb-auto-focus /> + umb-auto-focus + no-dirty-check />
        @@ -27,9 +28,8 @@
      - + There are no macros available to insert @@ -52,9 +52,8 @@
    - + There are no parameters for this macro diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.controller.js index a6a2ddcbab..ccb033a57c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.controller.js @@ -56,6 +56,46 @@ angular.module("umbraco") $scope.target = dialogOptions.currentTarget; } + function onInit() { + if ($scope.startNodeId !== -1) { + entityResource.getById($scope.startNodeId, "media") + .then(function (ent) { + $scope.startNodeId = ent.id; + run(); + }); + } else { + run(); + } + } + + function run() { + //default root item + if (!$scope.target) { + if ($scope.lastOpenedNode && $scope.lastOpenedNode !== -1) { + entityResource.getById($scope.lastOpenedNode, "media") + .then(ensureWithinStartNode, gotoStartNode); + } else { + gotoStartNode(); + } + } else { + //if a target is specified, go look it up - generally this target will just contain ids not the actual full + //media object so we need to look it up + var id = $scope.target.udi ? $scope.target.udi : $scope.target.id + var altText = $scope.target.altText; + mediaResource.getById(id) + .then(function (node) { + $scope.target = node; + if (ensureWithinStartNode(node)) { + selectImage(node); + $scope.target.url = mediaHelper.resolveFile(node); + $scope.target.altText = altText; + $scope.openDetailsDialog(); + } + }, + gotoStartNode); + } + } + $scope.upload = function(v) { angular.element(".umb-file-dropzone-directive .file-select").click(); }; @@ -107,7 +147,7 @@ angular.module("umbraco") if (folder.id > 0) { entityResource.getAncestors(folder.id, "media") - .then(function(anc) { + .then(function(anc) { $scope.path = _.filter(anc, function(f) { return f.path.indexOf($scope.startNodeId) !== -1; @@ -218,32 +258,6 @@ angular.module("umbraco") $scope.gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" }); } - //default root item - if (!$scope.target) { - if ($scope.lastOpenedNode && $scope.lastOpenedNode !== -1) { - entityResource.getById($scope.lastOpenedNode, "media") - .then(ensureWithinStartNode, gotoStartNode); - } else { - gotoStartNode(); - } - } else { - //if a target is specified, go look it up - generally this target will just contain ids not the actual full - //media object so we need to look it up - var id = $scope.target.udi ? $scope.target.udi : $scope.target.id - var altText = $scope.target.altText; - mediaResource.getById(id) - .then(function(node) { - $scope.target = node; - if (ensureWithinStartNode(node)) { - selectImage(node); - $scope.target.url = mediaHelper.resolveFile(node); - $scope.target.altText = altText; - $scope.openDetailsDialog(); - } - }, - gotoStartNode); - } - $scope.openDetailsDialog = function() { $scope.mediaPickerDetailsOverlay = {}; @@ -368,4 +382,7 @@ angular.module("umbraco") } } } + + onInit(); + }); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.html index 8c726f4a17..6ce53e7571 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/mediaPicker/mediapicker.html @@ -3,159 +3,147 @@ enctype="multipart/form-data" umb-image-upload="options"> -
    +
    -
    +
    - - + + - + -
    - - -
    +
    + + +
    -
    +
    -
    - +
    +
    - - + + - - + +
    - +
    - + -
    +
    - + -
    +
    -
    - - -
    +
    + + +
    -
    +
    -
    - Preview -
    +
    + Preview +
    - - + + -
    +
    -
    +
    -
    - - -
    +
    + + +
    -
    - - -
    +
    + + +
    -
    +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js index b18aa96588..700b18b518 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js @@ -1,5 +1,5 @@ angular.module("umbraco") - .controller("Umbraco.Overlays.UserController", function ($scope, $location, $timeout, userService, historyService, eventsService, externalLoginInfo, authResource, currentUserResource, formHelper, localizationService) { + .controller("Umbraco.Overlays.UserController", function ($scope, $location, $timeout, dashboardResource, userService, historyService, eventsService, externalLoginInfo, authResource, currentUserResource, formHelper, localizationService) { $scope.history = historyService.getCurrent(); $scope.version = Umbraco.Sys.ServerVariables.application.version + " assembly: " + Umbraco.Sys.ServerVariables.application.assemblyVersion; @@ -169,10 +169,13 @@ angular.module("umbraco") $scope.showPasswordFields = !$scope.showPasswordFields; } - function clearPasswordFields() { + function clearPasswordFields() { $scope.changePasswordModel.value.oldPassword = ""; $scope.changePasswordModel.value.newPassword = ""; $scope.changePasswordModel.value.confirm = ""; } - + + dashboardResource.getDashboard("user-dialog").then(function (dashboard) { + $scope.dashboard = dashboard; + }); }); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html index 328bb02d76..8021d66a43 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html @@ -11,6 +11,7 @@

    +
      @@ -122,4 +126,14 @@
    +
    +
    +
    +
    +

    {{property.caption}}

    +
    +
    +
    +
    +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/usergrouppicker/usergrouppicker.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/usergrouppicker/usergrouppicker.html index 665645ad17..4768052844 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/usergrouppicker/usergrouppicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/usergrouppicker/usergrouppicker.html @@ -1,7 +1,6 @@
    - +
    @@ -9,17 +8,18 @@ - + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.controller.js new file mode 100644 index 0000000000..bdd7eb65d0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.controller.js @@ -0,0 +1,24 @@ +(function () { + "use strict"; + + function NodeNameController($scope) { + + var vm = this; + var element = angular.element($scope.model.currentStep.element); + + vm.error = false; + + vm.initNextStep = initNextStep; + + function initNextStep() { + if(element.val().toLowerCase() === 'home') { + $scope.model.nextStep(); + } else { + vm.error = true; + } + } + + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbIntroCreateContent.NodeNameController", NodeNameController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.html new file mode 100644 index 0000000000..53badd7843 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatecontent/nodename/nodename.html @@ -0,0 +1,29 @@ +
    + + + + + + + +
    Please enter Home in the field
    +
    + + + + + + +
    + +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.controller.js new file mode 100644 index 0000000000..fee52eb506 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.controller.js @@ -0,0 +1,24 @@ +(function () { + "use strict"; + + function DocTypeNameController($scope) { + + var vm = this; + var element = angular.element($scope.model.currentStep.element); + + vm.error = false; + + vm.initNextStep = initNextStep; + + function initNextStep() { + if(element.val().toLowerCase() === 'home page') { + $scope.model.nextStep(); + } else { + vm.error = true; + } + } + + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbIntroCreateDocType.DocTypeNameController", DocTypeNameController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.html new file mode 100644 index 0000000000..a0b4b529e8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/doctypename/doctypename.html @@ -0,0 +1,29 @@ +
    + + + + + + + +
    Please enter Home Page in the field
    +
    + + + + + + +
    + +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.controller.js new file mode 100644 index 0000000000..1ebe878928 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.controller.js @@ -0,0 +1,24 @@ +(function () { + "use strict"; + + function PropertyNameController($scope) { + + var vm = this; + var element = angular.element($scope.model.currentStep.element); + + vm.error = false; + + vm.initNextStep = initNextStep; + + function initNextStep() { + if (element.val().toLowerCase() === 'welcome text') { + $scope.model.nextStep(); + } else { + vm.error = true; + } + } + + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbIntroCreateDocType.PropertyNameController", PropertyNameController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.html new file mode 100644 index 0000000000..5748f65a95 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/propertyname/propertyname.html @@ -0,0 +1,29 @@ +
    + + + + + + + +
    Please enter Welcome Text in the field
    +
    + + + + + + +
    + +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.controller.js new file mode 100644 index 0000000000..c477204cb7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.controller.js @@ -0,0 +1,24 @@ +(function () { + "use strict"; + + function TabNameController($scope) { + + var vm = this; + var element = angular.element($scope.model.currentStep.element); + + vm.error = false; + + vm.initNextStep = initNextStep; + + function initNextStep() { + if (element.val().toLowerCase() === 'home') { + $scope.model.nextStep(); + } else { + vm.error = true; + } + } + + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbIntroCreateDocType.TabNameController", TabNameController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.html new file mode 100644 index 0000000000..baa0d3da9a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrocreatedoctype/tabname/tabname.html @@ -0,0 +1,29 @@ +
    + + + + + + + +
    Please enter Home in the field
    +
    + + + + + + +
    + +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.controller.js new file mode 100644 index 0000000000..ebda50481d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.controller.js @@ -0,0 +1,24 @@ +(function () { + "use strict"; + + function FolderNameController($scope) { + + var vm = this; + var element = angular.element($scope.model.currentStep.element); + + vm.error = false; + + vm.initNextStep = initNextStep; + + function initNextStep() { + if(element.val().toLowerCase() === "my images") { + $scope.model.nextStep(); + } else { + vm.error = true; + } + } + + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbIntroMediaSection.FolderNameController", FolderNameController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.html new file mode 100644 index 0000000000..1a9bd41226 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/foldername/foldername.html @@ -0,0 +1,29 @@ +
    + + + + + + + +
    Please enter My Images in the field
    +
    + + + + + + +
    + +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.controller.js new file mode 100644 index 0000000000..0989076e95 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.controller.js @@ -0,0 +1,41 @@ +(function () { + "use strict"; + + function UploadImagesController($scope, editorState, mediaResource) { + + var vm = this; + var element = angular.element($scope.model.currentStep.element); + + vm.error = false; + + vm.initNextStep = initNextStep; + + function initNextStep() { + + vm.error = false; + vm.buttonState = "busy"; + + var currentNode = editorState.getCurrent(); + + // make sure we have uploaded at least one image + mediaResource.getChildren(currentNode.id) + .then(function (data) { + + var children = data; + + if(children.items && children.items.length > 0) { + $scope.model.nextStep(); + } else { + vm.error = true; + } + + vm.buttonState = "init"; + + }); + + } + + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbIntroMediaSection.UploadImagesController", UploadImagesController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.html new file mode 100644 index 0000000000..e7e8750823 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintromediasection/uploadimages/uploadimages.html @@ -0,0 +1,29 @@ +
    + + + + + + + +
    Please upload an image
    +
    + + + + + + +
    + +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.controller.js new file mode 100644 index 0000000000..3fdeb8d3c0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.controller.js @@ -0,0 +1,21 @@ +(function () { + "use strict"; + + function TemplatesTreeController($scope) { + + var vm = this; + var eventElement = angular.element($scope.model.currentStep.eventElement); + + function onInit() { + // check if tree is already open - if it is - go to next step + if(eventElement.hasClass("icon-navigation-down")) { + $scope.model.nextStep(); + } + } + + onInit(); + + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbIntroRenderInTemplate.TemplatesTreeController", TemplatesTreeController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.html new file mode 100644 index 0000000000..cc8896c964 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbintrorenderintemplate/templatetree/templatetree.html @@ -0,0 +1,22 @@ +
    + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-backdrop.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-backdrop.html new file mode 100644 index 0000000000..da1f61ee4a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-backdrop.html @@ -0,0 +1,19 @@ +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-contextmenu.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-contextmenu.html index 0aa58c8fae..18020ffd59 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-contextmenu.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-contextmenu.html @@ -5,9 +5,8 @@
      -
    • - +
    • + {{action.name}} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html index d3eed009c3..faf32173cc 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html @@ -1,114 +1,115 @@
      - - + + - -
    • -
    + + + + + + + +
    + +
    + + +
    + + +
    - + - - - diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-sections.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-sections.html index e9d50939f2..9fa8fa6891 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-sections.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-sections.html @@ -1,7 +1,7 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-tour.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-tour.html new file mode 100644 index 0000000000..b801d63cca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-tour.html @@ -0,0 +1,86 @@ +
    + +
    +
    + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +

    Congratulations!

    +

    You have reached the end of the {{model.name}} tour - way to go!

    +
    + + + + + +
    + +
    + + +
    + + +
    + + +

    Oh, we got lost!

    +
    + +

    We lost the next step {{ model.currentStep.title }} and don't know where to go.

    +

    Please go back and start the tour again.

    +
    + + + +
    +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-content.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-content.html new file mode 100644 index 0000000000..6bf6db6dc9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-content.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-footer.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-footer.html new file mode 100644 index 0000000000..e504fa7634 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-footer.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-header.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-header.html new file mode 100644 index 0000000000..900c7f76c9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-header.html @@ -0,0 +1,4 @@ +
    +
    {{ title }}
    +
    {{ description }}
    +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-view.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-view.html new file mode 100644 index 0000000000..a0c69a70f3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer-view.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer.html new file mode 100644 index 0000000000..89c9963810 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbdrawer/umb-drawer.html @@ -0,0 +1,3 @@ +
    +
    +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-content.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-content.html new file mode 100644 index 0000000000..0010a2bc6d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-content.html @@ -0,0 +1,4 @@ +
    +
    +
    +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-counter.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-counter.html new file mode 100644 index 0000000000..bd4e0127d2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-counter.html @@ -0,0 +1 @@ +
    {{ currentStep }}/{{ totalSteps }}
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-footer.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-footer.html new file mode 100644 index 0000000000..82cc711cfa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-footer.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-header.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-header.html new file mode 100644 index 0000000000..e34aa2025c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step-header.html @@ -0,0 +1,4 @@ +
    +
    {{title}}
    +
    +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step.html new file mode 100644 index 0000000000..90423ed603 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umbtour/umb-tour-step.html @@ -0,0 +1,10 @@ +
    + + +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html b/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html index 46e1adbde2..8117952b59 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html @@ -1,6 +1,8 @@
    - + - + diff --git a/src/Umbraco.Web.UI.Client/src/views/components/umb-date-time-picker.html b/src/Umbraco.Web.UI.Client/src/views/components/umb-date-time-picker.html index 6b37381113..2d12673a1f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/umb-date-time-picker.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/umb-date-time-picker.html @@ -1,6 +1,12 @@ -
    - - - - +
    + +
    + + + + +
    + +
    +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/umb-groups-builder.html b/src/Umbraco.Web.UI.Client/src/views/components/umb-groups-builder.html index 98e2443085..214b276d00 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/umb-groups-builder.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/umb-groups-builder.html @@ -1,10 +1,11 @@ -
    +
    -
  • +
  • - +
    @@ -247,7 +256,7 @@
    -
    +
    @@ -255,7 +264,7 @@
    -
    +
    + data-element="overlay-compositions" + ng-if="compositionsDialogModel.show" + model="compositionsDialogModel" + position="right" + view="compositionsDialogModel.view"> + data-element="overlay-property-settings" + ng-if="propertySettingsDialogModel.show" + model="propertySettingsDialogModel" + position="right" + view="propertySettingsDialogModel.view">
    diff --git a/src/Umbraco.Web.UI.Client/src/views/components/umb-media-grid.html b/src/Umbraco.Web.UI.Client/src/views/components/umb-media-grid.html index 412a57288d..9df33e7e0e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/umb-media-grid.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/umb-media-grid.html @@ -1,9 +1,9 @@ -
    -
    +
    +
    - diff --git a/src/Umbraco.Web.UI.Client/src/views/components/umb-progress-circle.html b/src/Umbraco.Web.UI.Client/src/views/components/umb-progress-circle.html new file mode 100644 index 0000000000..3864042e9f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/umb-progress-circle.html @@ -0,0 +1,7 @@ +
    + + + + +
    {{ percentage }}%
    +
    \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/components/umb-table.html b/src/Umbraco.Web.UI.Client/src/views/components/umb-table.html index 9b60e5e92f..8c5c7fcc4a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/umb-table.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/umb-table.html @@ -47,7 +47,17 @@
    - {{item[column.alias]}} + + +
    + {{item[column.alias]}} +
    + + + + + +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html index 6b05593e11..211060c03f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html @@ -1,4 +1,4 @@ -
    +
    @@ -23,7 +23,9 @@ -
    -
  • +
  • diff --git a/src/Umbraco.Web.UI.Client/src/views/content/edit.html b/src/Umbraco.Web.UI.Client/src/views/content/edit.html index 2ad4534115..c1a17662a2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/content/edit.html @@ -1,4 +1,4 @@ -
    +

    - Restore {{currentNode.name}} to under {{target.name}}? + Restore {{currentNode.name}} under {{target.name}}?

    -

    {{error.errorMsg}}

    -

    {{error.data.Message}}

    +
    {{error.errorMsg}}
    +
    {{error.data.Message}}
    diff --git a/src/Umbraco.Web.UI.Client/src/views/dashboard/dashboard.tabs.controller.js b/src/Umbraco.Web.UI.Client/src/views/dashboard/dashboard.tabs.controller.js index d0823d1815..2cb4e7be50 100644 --- a/src/Umbraco.Web.UI.Client/src/views/dashboard/dashboard.tabs.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/dashboard/dashboard.tabs.controller.js @@ -18,10 +18,25 @@ function startUpVideosDashboardController($scope, xmlhelper, $log, $http) { angular.module("umbraco").controller("Umbraco.Dashboard.StartupVideosController", startUpVideosDashboardController); -function startUpDynamicContentController(dashboardResource, assetsService) { +function startUpDynamicContentController($timeout, dashboardResource, assetsService, tourService, eventsService) { var vm = this; + var evts = []; + vm.loading = true; vm.showDefault = false; + + vm.startTour = startTour; + + function onInit() { + // load tours + tourService.getGroupedTours().then(function(groupedTours) { + vm.tours = groupedTours; + }); + } + + function startTour(tour) { + tourService.startTour(tour); + } // default dashboard content vm.defaultDashboard = { @@ -67,6 +82,17 @@ function startUpDynamicContentController(dashboardResource, assetsService) { ] }; + evts.push(eventsService.on("appState.tour.complete", function (name, completedTour) { + $timeout(function(){ + angular.forEach(vm.tours, function (tourGroup) { + angular.forEach(tourGroup, function (tour) { + if(tour.alias === completedTour.alias) { + tour.completed = true; + } + }); + }); + }); + })); //proxy remote css through the local server assetsService.loadCss( dashboardResource.getRemoteDashboardCssUrl("content") ); @@ -90,6 +116,10 @@ function startUpDynamicContentController(dashboardResource, assetsService) { vm.loading = false; vm.showDefault = true; }); + + + onInit(); + } angular.module("umbraco").controller("Umbraco.Dashboard.StartUpDynamicContentController", startUpDynamicContentController); diff --git a/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/developerdashboardvideos.html b/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/developerdashboardvideos.html index bbf94629a0..0ad779cf3e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/developerdashboardvideos.html +++ b/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/developerdashboardvideos.html @@ -1,5 +1,5 @@

    Hours of Umbraco training videos are only a click away

    -

    Want to master Umbraco? Spend a couple of minutes learning some best practices by watching one of these videos about using Umbraco. And visit umbraco.tv for even more Umbraco videos

    +

    Want to master Umbraco? Spend a couple of minutes learning some best practices by watching one of these videos about using Umbraco, then visit umbraco.tv for even more Umbraco videos.

  • - + +
    + {{video.title}} +
    {{video.title}}
    +
    +
diff --git a/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/examinemanagement.html b/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/examinemanagement.html index e80e7775da..1c166c54ab 100644 --- a/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/examinemanagement.html +++ b/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/examinemanagement.html @@ -215,7 +215,7 @@
diff --git a/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/redirecturls.html b/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/redirecturls.html index f23b8f5df9..0ecaeb80df 100644 --- a/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/redirecturls.html +++ b/src/Umbraco.Web.UI.Client/src/views/dashboard/developer/redirecturls.html @@ -19,7 +19,7 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/dashboard/forms/formsdashboardintro.html b/src/Umbraco.Web.UI.Client/src/views/dashboard/forms/formsdashboardintro.html index 7ef9537080..3b382367c4 100644 --- a/src/Umbraco.Web.UI.Client/src/views/dashboard/forms/formsdashboardintro.html +++ b/src/Umbraco.Web.UI.Client/src/views/dashboard/forms/formsdashboardintro.html @@ -2,7 +2,7 @@
- +

Umbraco Forms

diff --git a/src/Umbraco.Web.UI.Client/src/views/datatypes/create.html b/src/Umbraco.Web.UI.Client/src/views/datatypes/create.html index 5550fe8b10..4875e4b1ea 100644 --- a/src/Umbraco.Web.UI.Client/src/views/datatypes/create.html +++ b/src/Umbraco.Web.UI.Client/src/views/datatypes/create.html @@ -4,7 +4,7 @@
Create an item under {{currentNode.name}}
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.controller.js index bde9cac30d..0cd199ae4d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.controller.js @@ -77,14 +77,14 @@ function includePropsPreValsController($rootScope, $scope, localizationService, // Return a helper with preserved width of cells var fixHelper = function (e, ui) { - var h = ui.clone(); - - h.children().each(function () { - $(this).width($(this).width()); + ui.children().each(function () { + $(this).width($(this).width()); }); - h.css("background-color", "lightgray"); - return h; + var row = ui.clone(); + row.css("background-color", "lightgray"); + + return row; }; $scope.sortableOptions = { @@ -96,6 +96,10 @@ function includePropsPreValsController($rootScope, $scope, localizationService, cursor: 'move', items: '> tr', tolerance: 'pointer', + forcePlaceholderSize: true, + start: function(e, ui){ + ui.placeholder.height(ui.item.height()); + }, update: function (e, ui) { // Get the new and old index for the moved element (using the text as the identifier) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.html index 5cf7de7de6..2e210e635f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/includeproperties.prevalues.html @@ -20,7 +20,7 @@
- +
Set the title of the overlay.
model.subTitlemodel.subtitle String Set the subtitle of the overlay.
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/layouts/list/list.listviewlayout.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/layouts/list/list.listviewlayout.controller.js index 799cc5894c..00e6c6edb4 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/layouts/list/list.listviewlayout.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/layouts/list/list.listviewlayout.controller.js @@ -30,15 +30,14 @@ vm.dragLeave = dragLeave; vm.onFilesQueue = onFilesQueue; vm.onUploadComplete = onUploadComplete; + markAsSensitive(); function activate() { - if ($scope.entityType === 'media') { mediaTypeHelper.getAllowedImagetypes(vm.nodeId).then(function (types) { vm.acceptedMediatypes = types; }); } - } function selectAll($event) { @@ -87,6 +86,27 @@ $scope.getContent($scope.contentId); } + function markAsSensitive() { + angular.forEach($scope.options.includeProperties, function (option) { + option.isSensitive = false; + + angular.forEach($scope.items, + function (item) { + + angular.forEach(item.properties, + function (property) { + + if (option.alias === property.alias) { + option.isSensitive = property.isSensitive; + } + + }); + + }); + + }); + } + activate(); } diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js index 53424a7d6e..7a3abdd0e6 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js @@ -46,6 +46,7 @@ function MarkdownEditorController($scope, $element, assetsService, dialogService // init the md editor after this digest because the DOM needs to be ready first // so run the init on a timeout $timeout(function () { + $scope.markdownEditorInitComplete = false; var converter2 = new Markdown.Converter(); var editor2 = new Markdown.Editor(converter2, "-" + $scope.model.alias); editor2.run(); @@ -59,7 +60,12 @@ function MarkdownEditorController($scope, $element, assetsService, dialogService editor2.hooks.set("onPreviewRefresh", function () { // We must manually update the model as there is no way to hook into the markdown editor events without exstensive edits to the library. if ($scope.model.value !== $("textarea", $element).val()) { - angularHelper.getCurrentForm($scope).$setDirty(); + if ($scope.markdownEditorInitComplete) { + //only set dirty after init load to avoid "unsaved" dialogue when we don't want it + angularHelper.getCurrentForm($scope).$setDirty(); + } else { + $scope.markdownEditorInitComplete = true; + } $scope.model.value = $("textarea", $element).val(); } }); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.controller.js index 8a7b20498d..bf7462942a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.controller.js @@ -1,7 +1,7 @@ //this controller simply tells the dialogs service to open a mediaPicker window //with a specified callback, this callback will receive an object with a selection on it angular.module('umbraco').controller("Umbraco.PropertyEditors.MediaPickerController", - function ($rootScope, $scope, dialogService, entityResource, mediaResource, mediaHelper, $timeout, userService, $location) { + function ($rootScope, $scope, dialogService, entityResource, mediaResource, mediaHelper, $timeout, userService, $location, localizationService) { //check the pre-values for multi-picker var multiPicker = $scope.model.config.multiPicker && $scope.model.config.multiPicker !== '0' ? true : false; @@ -19,6 +19,8 @@ angular.module('umbraco').controller("Umbraco.PropertyEditors.MediaPickerControl $scope.images = []; $scope.ids = []; + $scope.isMultiPicker = multiPicker; + if ($scope.model.value) { var ids = $scope.model.value.split(','); @@ -26,17 +28,47 @@ angular.module('umbraco').controller("Umbraco.PropertyEditors.MediaPickerControl // the mediaResource has server side auth configured for which the user must have // access to the media section, if they don't they'll get auth errors. The entityResource // acts differently in that it allows access if the user has access to any of the apps that - // might require it's use. Therefore we need to use the metatData property to get at the thumbnail + // might require it's use. Therefore we need to use the metaData property to get at the thumbnail // value. - entityResource.getByIds(ids, "Media").then(function (medias) { + entityResource.getByIds(ids, "Media").then(function(medias) { - _.each(medias, function (media, i) { + // The service only returns item results for ids that exist (deleted items are silently ignored). + // This results in the picked items value to be set to contain only ids of picked items that could actually be found. + // Since a referenced item could potentially be restored later on, instead of changing the selected values here based + // on whether the items exist during a save event - we should keep "placeholder" items for picked items that currently + // could not be fetched. This will preserve references and ensure that the state of an item does not differ depending + // on whether it is simply resaved or not. + // This is done by remapping the int/guid ids into a new array of items, where we create "Deleted item" placeholders + // when there is no match for a selected id. This will ensure that the values being set on save, are the same as before. + + medias = _.map(ids, + function(id) { + var found = _.find(medias, + function(m) { + // We could use coercion (two ='s) here .. but not sure if this works equally well in all browsers and + // it's prone to someone "fixing" it at some point without knowing the effects. Rather use toString() + // compares and be completely sure it works. + return m.udi.toString() === id.toString() || m.id.toString() === id.toString(); + }); + if (found) { + return found; + } else { + return { + name: localizationService.dictionary.mediaPicker_deletedItem, + id: $scope.model.config.idType !== "udi" ? id : null, + udi: $scope.model.config.idType === "udi" ? id : null, + icon: "icon-picture", + thumbnail: null, + trashed: true + }; + } + }); - //only show non-trashed items - if (media.parentId >= -1) { - - if (!media.thumbnail) { + _.each(medias, + function(media, i) { + // if there is no thumbnail, try getting one if the media is not a placeholder item + if (!media.thumbnail && media.id && media.metaData) { media.thumbnail = mediaHelper.resolveFileFromEntity(media, true); } @@ -44,12 +76,10 @@ angular.module('umbraco').controller("Umbraco.PropertyEditors.MediaPickerControl if ($scope.model.config.idType === "udi") { $scope.ids.push(media.udi); - } - else { + } else { $scope.ids.push(media.id); } - } - }); + }); $scope.sync(); }); @@ -82,8 +112,8 @@ angular.module('umbraco').controller("Umbraco.PropertyEditors.MediaPickerControl submit: function(model) { _.each(model.selectedImages, function(media, i) { - - if (!media.thumbnail) { + // if there is no thumbnail, try getting one if the media is not a placeholder item + if (!media.thumbnail && media.id && media.metaData) { media.thumbnail = mediaHelper.resolveFileFromEntity(media, true); } @@ -101,17 +131,18 @@ angular.module('umbraco').controller("Umbraco.PropertyEditors.MediaPickerControl $scope.mediaPickerOverlay.show = false; $scope.mediaPickerOverlay = null; - } }; - }; $scope.sortableOptions = { + disabled: !$scope.isMultiPicker, + items: "li:not(.add-wrapper)", + cancel: ".unsortable", update: function(e, ui) { var r = []; - //TODO: Instead of doing this with a half second delay would be better to use a watch like we do in the - // content picker. THen we don't have to worry about setting ids, render models, models, we just set one and let the + // TODO: Instead of doing this with a half second delay would be better to use a watch like we do in the + // content picker. Then we don't have to worry about setting ids, render models, models, we just set one and let the // watch do all the rest. $timeout(function(){ angular.forEach($scope.images, function(value, key) { @@ -142,5 +173,4 @@ angular.module('umbraco').controller("Umbraco.PropertyEditors.MediaPickerControl //update the display val again if it has changed from the server setupViewModel(); }; - }); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.html index f3aab992dd..7607a9391d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker/mediapicker.html @@ -1,47 +1,48 @@
-
    -
  • +

    +

    + +
    + +
    -
  • -
- - - - - - + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/relatedlinks/relatedlinks.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/relatedlinks/relatedlinks.controller.js index 9b4fb217eb..51c5baf55b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/relatedlinks/relatedlinks.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/relatedlinks/relatedlinks.controller.js @@ -86,6 +86,10 @@ }; $scope.add = function ($event) { + if (!angular.isArray($scope.model.value)) { + $scope.model.value = []; + } + if ($scope.newCaption == "") { $scope.hasError = true; } else { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js index d17310d65a..0a6bededae 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js @@ -139,6 +139,7 @@ angular.module("umbraco") //wait for queue to end $q.all(await).then(function () { + //create a baseline Config to exten upon var baseLineConfigObj = { mode: "exact", @@ -149,14 +150,16 @@ angular.module("umbraco") extended_valid_elements: extendedValidElements, menubar: false, statusbar: false, + relative_urls: false, height: editorConfig.dimensions.height, width: editorConfig.dimensions.width, maxImageSize: editorConfig.maxImageSize, toolbar: toolbar, content_css: stylesheets, - relative_urls: false, style_formats: styleFormats, - language: language + language: language, + //see http://archive.tinymce.com/wiki.php/Configuration:cache_suffix + cache_suffix: "?umb__rnd=" + Umbraco.Sys.ServerVariables.application.cacheBuster }; if (tinyMceConfig.customConfig) { @@ -356,7 +359,8 @@ angular.module("umbraco") //this is instead of doing a watch on the model.value = faster $scope.model.onValueChanged = function (newVal, oldVal) { //update the display val again if it has changed from the server; - tinyMceEditor.setContent(newVal, { format: 'raw' }); + //uses an empty string in the editor when the value is null + tinyMceEditor.setContent(newVal || "", { format: 'raw' }); //we need to manually fire this event since it is only ever fired based on loading from the DOM, this // is required for our plugins listening to this event to execute tinyMceEditor.fire('LoadContent', null); @@ -374,6 +378,9 @@ angular.module("umbraco") // element might still be there even after the modal has been hidden. $scope.$on('$destroy', function () { unsubscribe(); + if (tinyMceEditor !== undefined && tinyMceEditor != null) { + tinyMceEditor.destroy() + } }); }); }); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js index 702d19a509..65a62f599c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js @@ -76,6 +76,7 @@ angular.module("umbraco").controller("Umbraco.PrevalueEditors.RteController", icon.isCustom = false; break; case "styleselect": + case "fontsizeselect": icon.name = "icon-list"; icon.isCustom = true; break; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/sensitivevalue/sensitivevalue.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/sensitivevalue/sensitivevalue.html new file mode 100644 index 0000000000..6460d882b2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/sensitivevalue/sensitivevalue.html @@ -0,0 +1,5 @@ +
+ + Hide this property value from content editors that don't have access to view sensitive information + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/slider/slider.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/slider/slider.controller.js index 404c311d8b..321ac13555 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/slider/slider.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/slider/slider.controller.js @@ -92,7 +92,6 @@ $scope.model.config.ticksPositions = _.map($scope.model.config.ticksPositions.split(','), function (item) { return parseInt(item.trim()); }); - console.log($scope.model.config.ticksPositions); } if (!$scope.model.config.ticksLabels) { @@ -215,4 +214,4 @@ assetsService.loadCss("lib/slider/bootstrap-slider.css"); assetsService.loadCss("lib/slider/bootstrap-slider-custom.css"); } -angular.module("umbraco").controller("Umbraco.PropertyEditors.SliderController", sliderController); \ No newline at end of file +angular.module("umbraco").controller("Umbraco.PropertyEditors.SliderController", sliderController); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js index 3bb909c717..734903e46c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js @@ -1,37 +1,42 @@ function textboxController($scope) { - // macro parameter editor doesn't contains a config object, - // so we create a new one to hold any properties + // so we create a new one to hold any properties if (!$scope.model.config) { $scope.model.config = {}; } - if (!$scope.model.config.maxChars) { - $scope.model.config.maxChars = false; - } - $scope.model.maxlength = false; if ($scope.model.config && $scope.model.config.maxChars) { $scope.model.maxlength = true; - if($scope.model.value == undefined) { + } + + if (!$scope.model.config.maxChars) { + // 500 is the maximum number that can be stored + // in the database, so set it to the max, even + // if no max is specified in the config + $scope.model.config.maxChars = 500; + } + + if ($scope.model.maxlength) { + if ($scope.model.value === undefined) { $scope.model.count = ($scope.model.config.maxChars * 1); } else { $scope.model.count = ($scope.model.config.maxChars * 1) - $scope.model.value.length; } } - $scope.model.change = function() { + $scope.model.change = function () { if ($scope.model.config && $scope.model.config.maxChars) { - if($scope.model.value == undefined) { + if ($scope.model.value === undefined) { $scope.model.count = ($scope.model.config.maxChars * 1); } else { $scope.model.count = ($scope.model.config.maxChars * 1) - $scope.model.value.length; } - if($scope.model.count < 0) { + if ($scope.model.count < 0) { $scope.model.value = $scope.model.value.substring(0, ($scope.model.config.maxChars * 1)); $scope.model.count = 0; } } } } -angular.module('umbraco').controller("Umbraco.PropertyEditors.textboxController", textboxController); \ No newline at end of file +angular.module('umbraco').controller("Umbraco.PropertyEditors.textboxController", textboxController); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html index d8c51ce9e0..77656ac618 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html @@ -4,11 +4,11 @@ val-server="value" ng-required="model.validation.mandatory" ng-trim="false" - ng-keyup="model.change()" /> + ng-keyup="model.change()" /> Required -
- {{model.count}} - characters left -
- \ No newline at end of file +
+ {{model.count}} + characters left +
+ diff --git a/src/Umbraco.Web.UI.Client/src/views/scripts/edit.html b/src/Umbraco.Web.UI.Client/src/views/scripts/edit.html index 4c58356732..9b03935efc 100644 --- a/src/Umbraco.Web.UI.Client/src/views/scripts/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/scripts/edit.html @@ -1,4 +1,4 @@ -
+
@@ -21,6 +21,7 @@
diff --git a/src/Umbraco.Web.UI.Client/src/views/templates/edit.html b/src/Umbraco.Web.UI.Client/src/views/templates/edit.html index 2d59b644d0..279cdc538d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/templates/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/templates/edit.html @@ -1,4 +1,4 @@ -
+
@@ -30,6 +30,7 @@
@@ -113,6 +118,7 @@ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/users/user.controller.js b/src/Umbraco.Web.UI.Client/src/views/users/user.controller.js index ec072b9268..0f3519c7ba 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/user.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/users/user.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function UserEditController($scope, $timeout, $location, $routeParams, formHelper, usersResource, contentEditingHelper, localizationService, notificationsService, mediaHelper, Upload, umbRequestHelper, usersHelper, authResource, dateHelper) { + function UserEditController($scope, eventsService, $q, $timeout, $location, $routeParams, formHelper, usersResource, userService, contentEditingHelper, localizationService, notificationsService, mediaHelper, Upload, umbRequestHelper, usersHelper, authResource, dateHelper) { var vm = this; @@ -16,7 +16,7 @@ vm.maxFileSize = Umbraco.Sys.ServerVariables.umbracoSettings.maxFileSize + "KB"; vm.acceptedFileTypes = mediaHelper.formatFileTypes(Umbraco.Sys.ServerVariables.umbracoSettings.imageFileTypes); vm.usernameIsEmail = Umbraco.Sys.ServerVariables.umbracoSettings.usernameIsEmail; - + //create the initial model for change password vm.changePasswordModel = { config: {}, @@ -31,8 +31,10 @@ vm.disableUser = disableUser; vm.enableUser = enableUser; vm.unlockUser = unlockUser; + vm.changeAvatar = changeAvatar; vm.clearAvatar = clearAvatar; vm.save = save; + vm.toggleChangePassword = toggleChangePassword; function init() { @@ -92,7 +94,7 @@ }); } - function getLocalDate(date, format) { + function getLocalDate(date, culture, format) { if(date) { var dateVal; var serverOffset = Umbraco.Sys.ServerVariables.application.serverTimeOffset; @@ -105,7 +107,7 @@ dateVal = moment(date, "YYYY-MM-DD HH:mm:ss"); } - return dateVal.format(format); + return dateVal.locale(culture).format(format); } } @@ -117,38 +119,83 @@ function save() { - vm.page.saveButtonState = "busy"; - vm.user.resetPasswordValue = null; + if (formHelper.submitForm({ scope: $scope, statusMessage: vm.labels.saving })) { - //anytime a user is changing another user's password, we are in effect resetting it so we need to set that flag here - if(vm.user.changePassword) { - vm.user.changePassword.reset = !vm.user.changePassword.oldPassword && !vm.user.isCurrentUser; + //anytime a user is changing another user's password, we are in effect resetting it so we need to set that flag here + if (vm.user.changePassword) { + vm.user.changePassword.reset = !vm.user.changePassword.oldPassword && !vm.user.isCurrentUser; + } + + vm.page.saveButtonState = "busy"; + vm.user.resetPasswordValue = null; + + //save current nav to be restored later so that the tabs dont change + var currentNav = vm.user.navigation; + + usersResource.saveUser(vm.user) + .then(function (saved) { + + //if the user saved, then try to execute all extended save options + extendedSave(saved).then(function(result) { + //if all is good, then reset the form + formHelper.resetForm({ scope: $scope, notifications: saved.notifications }); + }, function(err) { + //otherwise show the notifications for the user being saved + formHelper.showNotifications(saved); + }); + + vm.user = _.omit(saved, "navigation"); + //restore + vm.user.navigation = currentNav; + setUserDisplayState(); + formatDatesToLocal(vm.user); + + vm.changePasswordModel.isChanging = false; + //the user has a password if they are not states: Invited, NoCredentials + vm.changePasswordModel.config.hasPassword = vm.user.userState !== 3 && vm.user.userState !== 4; + + vm.page.saveButtonState = "success"; + + }, function (err) { + + contentEditingHelper.handleSaveError({ + redirectOnFailure: false, + err: err + }); + //show any notifications + if (err.data) { + formHelper.showNotifications(err.data); + } + vm.page.saveButtonState = "error"; + }); } + } - contentEditingHelper.contentEditorPerformSave({ - statusMessage: vm.labels.saving, - saveMethod: usersResource.saveUser, - scope: $scope, - content: vm.user, - // We do not redirect on failure for users - this is because it is not possible to actually save a user - // when server side validation fails - as opposed to content where we are capable of saving the content - // item if server side validation fails - redirectOnFailure: false, - rebindCallback: function (orignal, saved) { } - }).then(function (saved) { + /** + * Used to emit the save event and await any async operations being performed by editor extensions + * @param {any} savedUser + */ + function extendedSave(savedUser) { - vm.user = saved; - setUserDisplayState(); - formatDatesToLocal(vm.user); + //used to track any promises added by the event handlers to be awaited + var promises = []; + + var args = { + //getPromise: getPromise, + user: savedUser, + //a promise can be added by the event handler if the handler needs an async operation to be awaited + addPromise: function (p) { + promises.push(p); + } + }; - vm.changePasswordModel.isChanging = false; - vm.page.saveButtonState = "success"; - - //the user has a password if they are not states: Invited, NoCredentials - vm.changePasswordModel.config.hasPassword = vm.user.userState !== 3 && vm.user.userState !== 4; - }, function (err) { - vm.page.saveButtonState = "error"; - }); + //emit the event + eventsService.emit("editors.user.editController.save", args); + + //await all promises to complete + var resultPromise = $q.all(promises); + + return resultPromise; } function goToPage(ancestor) { @@ -310,7 +357,7 @@ }); } - $scope.changeAvatar = function (files, event) { + function changeAvatar(files, event) { if (files && files.length > 0) { upload(files[0]); } @@ -393,11 +440,14 @@ } function formatDatesToLocal(user) { - user.formattedLastLogin = getLocalDate(user.lastLoginDate, "MMMM Do YYYY, HH:mm"); - user.formattedLastLockoutDate = getLocalDate(user.lastLockoutDate, "MMMM Do YYYY, HH:mm"); - user.formattedCreateDate = getLocalDate(user.createDate, "MMMM Do YYYY, HH:mm"); - user.formattedUpdateDate = getLocalDate(user.updateDate, "MMMM Do YYYY, HH:mm"); - user.formattedLastPasswordChangeDate = getLocalDate(user.lastPasswordChangeDate, "MMMM Do YYYY, HH:mm"); + // get current backoffice user and format dates + userService.getCurrentUser().then(function (currentUser) { + user.formattedLastLogin = getLocalDate(user.lastLoginDate, currentUser.locale, "LLL"); + user.formattedLastLockoutDate = getLocalDate(user.lastLockoutDate, currentUser.locale, "LLL"); + user.formattedCreateDate = getLocalDate(user.createDate, currentUser.locale, "LLL"); + user.formattedUpdateDate = getLocalDate(user.updateDate, currentUser.locale, "LLL"); + user.formattedLastPasswordChangeDate = getLocalDate(user.lastPasswordChangeDate, currentUser.locale, "LLL"); + }); } init(); diff --git a/src/Umbraco.Web.UI.Client/src/views/users/user.html b/src/Umbraco.Web.UI.Client/src/views/users/user.html index e043b50d2c..32c1c3d641 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/users/user.html @@ -1,404 +1,27 @@
+ + - + - + - - - - +
-
- -
- - - - - - - - - - Required - - - - - - Required - - - - - - Required - - - - - - - - - - - - - - - - Add - - - - - - - - - - - - - - Add - - - - - - - - - - - - - - Add - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
- - -
- - - - - - - - - - - - -
- - -
- -
- - -
- -
- - -
- -
- - -
- - - - - - - - - - - - - - -
-


Password reset to value: {{vm.user.resetPasswordValue}}

-
- -
- - -
-
- Status: -
-
- - {{vm.user.userDisplayState.name}} - -
-
- -
-
- Last login: -
-
- {{ vm.user.formattedLastLogin }} - {{ vm.user.name | umbWordLimit:1 }} has not logged in yet -
-
- -
-
- Failed login attempts: -
-
- {{ vm.user.failedPasswordAttempts }} -
-
- -
-
- Last lockout date: -
-
- - {{ vm.user.name | umbWordLimit:1 }} hasn't been locked out - - {{ vm.user.formattedLastLockoutDate }} -
-
- -
-
- Password is last changed: -
-
- - The password hasn't been changed - - {{ vm.user.formattedLastPasswordChangeDate }} -
-
- -
-
- User is created: -
-
- {{ vm.user.formattedCreateDate }} -
-
- -
-
- User is last updated: -
-
- {{ vm.user.formattedUpdateDate }} -
-
- -
- -
- -
+ +
@@ -408,33 +31,30 @@ - + - + - + @@ -445,25 +65,22 @@ - + - + - +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/groups/groups.html b/src/Umbraco.Web.UI.Client/src/views/users/views/groups/groups.html index 931ab64775..efb88f6297 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/views/groups/groups.html +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/groups/groups.html @@ -53,6 +53,7 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html new file mode 100644 index 0000000000..67f204aa2f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html @@ -0,0 +1,361 @@ +
+ +
+ + + + + + + + + + + Required + + + + + + Required + + + + + + Required + + + + + + + + + + + + + + + + Add + + + + + + + + + + + + + + Add + + + + + + + + + + + + + + Add + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + +
+ + + + + + + + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + +
+


Password reset to value: {{model.user.resetPasswordValue}}

+
+ +
+ + +
+
+ Status: +
+
+ + {{model.user.userDisplayState.name}} + +
+
+ +
+
+ Last login: +
+
+ {{ model.user.formattedLastLogin }} + {{ model.user.name | umbWordLimit:1 }} has not logged in yet +
+
+ +
+
+ Failed login attempts: +
+
+ {{ model.user.failedPasswordAttempts }} +
+
+ +
+
+ Last lockout date: +
+
+ + {{ model.user.name | umbWordLimit:1 }} hasn't been locked out + + {{ model.user.formattedLastLockoutDate }} +
+
+ +
+
+ Password is last changed: +
+
+ + The password hasn't been changed + + {{ model.user.formattedLastPasswordChangeDate }} +
+
+ +
+
+ User is created: +
+
+ {{ model.user.formattedCreateDate }} +
+
+ +
+
+ User is last updated: +
+
+ {{ model.user.formattedUpdateDate }} +
+
+ +
+ +
+ +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js b/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js index 96ebf4f07b..e60fd4be74 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function UsersController($scope, $timeout, $location, usersResource, userGroupsResource, localizationService, contentEditingHelper, usersHelper, formHelper, notificationsService, dateHelper) { + function UsersController($scope, $timeout, $location, $routeParams, usersResource, userGroupsResource, userService, localizationService, contentEditingHelper, usersHelper, formHelper, notificationsService, dateHelper) { var vm = this; var localizeSaving = localizationService.localize("general_saving"); @@ -20,6 +20,19 @@ { label: "Oldest", key: "CreateDate", direction: "Ascending" }, { label: "Last login", key: "LastLoginDate", direction: "Descending" } ]; + + angular.forEach(vm.userSortData, function (userSortData) { + var key = "user_sort" + userSortData.key + userSortData.direction; + localizationService.localize(key).then(function (value) { + var reg = /^\[[\S\s]*]$/g; + var result = reg.test(value); + if (result === false) { + // Only translate if key exists + userSortData.label = value; + } + }); + }); + vm.userStatesFilter = []; vm.newUser.userGroups = []; vm.usersViewState = 'overview'; @@ -111,6 +124,13 @@ vm.usersOptions.orderBy = "Name"; vm.usersOptions.orderDirection = "Ascending"; + if ($routeParams.create) { + setUsersViewState("createUser"); + } + else if ($routeParams.invite) { + setUsersViewState("inviteUser"); + } + // Get users getUsers(); @@ -155,6 +175,17 @@ if (state === "createUser") { clearAddUserForm(); + + $location.search("create", "true"); + $location.search("invite", null); + } + else if (state === "inviteUser") { + $location.search("create", null); + $location.search("invite", "true"); + } + else if (state === "overview") { + $location.search("create", null); + $location.search("invite", null); } vm.usersViewState = state; @@ -284,10 +315,10 @@ vm.selectedBulkUserGroups = _.clone(firstSelectedUser.userGroups); vm.userGroupPicker = { - title: "Select user groups", + title: localizationService.localize("user_selectUserGroups"), view: "usergrouppicker", selection: vm.selectedBulkUserGroups, - closeButtonLabel: "Cancel", + closeButtonLabel: localizationService.localize("general_cancel"), show: true, submit: function (model) { usersResource.setUserGroupsOnUsers(model.selection, vm.selection).then(function (data) { @@ -320,10 +351,10 @@ function openUserGroupPicker(event) { vm.userGroupPicker = { - title: "Select user groups", + title: localizationService.localize("user_selectUserGroups"), view: "usergrouppicker", selection: vm.newUser.userGroups, - closeButtonLabel: "Cancel", + closeButtonLabel: localizationService.localize("general_cancel"), show: true, submit: function (model) { // apply changes @@ -583,7 +614,10 @@ dateVal = moment(user.lastLoginDate, "YYYY-MM-DD HH:mm:ss"); } - user.formattedLastLogin = dateVal.format("MMMM Do YYYY, HH:mm"); + // get current backoffice user and format date + userService.getCurrentUser().then(function (currentUser) { + user.formattedLastLogin = dateVal.locale(currentUser.locale).format("LLL"); + }); } }); } diff --git a/src/Umbraco.Web.UI.Client/test/unit/app/media/edit-media-controller.spec.js b/src/Umbraco.Web.UI.Client/test/unit/app/media/edit-media-controller.spec.js index 1c7b99cc01..e9e41f9073 100644 --- a/src/Umbraco.Web.UI.Client/test/unit/app/media/edit-media-controller.spec.js +++ b/src/Umbraco.Web.UI.Client/test/unit/app/media/edit-media-controller.spec.js @@ -1,6 +1,6 @@ describe('edit media controller tests', function () { var scope, controller, routeParams, httpBackend; - routeParams = {id: 1234, create: false}; + routeParams = { id: 1234, create: false }; beforeEach(module('umbraco')); @@ -11,7 +11,7 @@ describe('edit media controller tests', function () { httpBackend = $httpBackend; scope = $rootScope.$new(); - + //have the contentMocks register its expect urls on the httpbackend //see /mocks/content.mocks.js for how its setup mediaMocks.register(); @@ -20,7 +20,7 @@ describe('edit media controller tests', function () { //this controller requires an angular form controller applied to it scope.contentForm = angularHelper.getNullForm("contentForm"); - + controller = $controller('Umbraco.Editors.Media.EditController', { $scope: scope, $routeParams: routeParams @@ -37,8 +37,8 @@ describe('edit media controller tests', function () { })); describe('media edit controller save', function () { - - it('it should have an media object', function() { + + it('it should have an media object', function () { //controller should have a content object expect(scope.content).not.toBeUndefined(); @@ -48,22 +48,29 @@ describe('edit media controller tests', function () { }); it('it should have a tabs collection', function () { - expect(scope.content.tabs.length).toBe(1); + expect(scope.content.tabs.length).toBe(2); }); - it('it should have a properties collection on each tab', function () { - $(scope.content.tabs).each(function(i, tab){ - expect(tab.properties.length).toBeGreaterThan(0); - }); + it('it should have added an info tab', function () { + expect(scope.content.tabs[1].id).toBe(-1); + expect(scope.content.tabs[1].alias).toBe("_umb_infoTab"); + }); + + it('all other tabs than the info tab should have a properties collection', function () { + $(scope.content.tabs).each(function (i, tab) { + if (tab.id !== -1 && tab.alias !== '_umb_infoTab') { + expect(tab.properties.length).toBeGreaterThan(0); + } + }); }); it('it should change updateDate on save', function () { - var currentUpdateDate = scope.content.updateDate; + var currentUpdateDate = scope.content.updateDate; - setTimeout(function(){ - scope.save(scope.content); - expect(scope.content.updateDate).toBeGreaterThan(currentUpdateDate); - }, 1000); + setTimeout(function () { + scope.save(scope.content); + expect(scope.content.updateDate).toBeGreaterThan(currentUpdateDate); + }, 1000); }); }); diff --git a/src/Umbraco.Web.UI.Client/test/unit/common/directives/val-email.spec.js b/src/Umbraco.Web.UI.Client/test/unit/common/directives/val-email.spec.js index 265689988d..9a6e884ff6 100644 --- a/src/Umbraco.Web.UI.Client/test/unit/common/directives/val-email.spec.js +++ b/src/Umbraco.Web.UI.Client/test/unit/common/directives/val-email.spec.js @@ -27,7 +27,8 @@ expect(valEmailExpression.EMAIL_REGEXP.test('a@3b.c')).toBe(true); expect(valEmailExpression.EMAIL_REGEXP.test('a@b')).toBe(true); expect(valEmailExpression.EMAIL_REGEXP.test('abc@xyz.financial')).toBe(true); - + expect(valEmailExpression.EMAIL_REGEXP.test('admin@c.pizza')).toBe(true); + expect(valEmailExpression.EMAIL_REGEXP.test('admin+gmail-syntax@c.pizza')).toBe(true); }); }); diff --git a/src/WebPi/TBEX.xml b/src/WebPi/TBEX.xml deleted file mode 100644 index 827bd01574..0000000000 --- a/src/WebPi/TBEX.xml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - Start Here - First time here? Complete the installation of your Umbraco Site, select a Starter Kit and Skin to get you started. - /install/default.aspx - http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/umbraco.png - - - Getting Started with Umbraco - So you've finished installing Umbraco in WebMatrix, what next? Learn how to build and configure your new site! - http://our.umbraco.org/wiki/how-tos/getting-started-with-umbraco-what-is-next-after-you-install - http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/umbraco.png - - - Extending Umbraco using WebMatrix - In this 5-minute video tutorial you'll learn how to edit templates, use the Razor syntax to add dynamic functionality and deploy an Umbraco site - all from within WebMatrix. - http://umbraco.com/help-and-support/video-tutorials/getting-started/working-with-webmatrix - http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/umbraco.png - - - Umbraco Forum - Where the friendliest CMS community on the planet helps each other - Search for documentation, get help and guidance from seasoned experts, download and collaborate on plugins and extensions. - http://our.umbraco.org/ - http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/community.png - - - Umbraco Products - We have additional products and services available to help you get the most out of Umbraco. - http://umbraco.com/products - http://umbraco.com/products - http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/products.png - - - Umbraco Community Projects - "The Deli" - Find free and commercial add-ons, extensions, skins, and starter sites. - http://our.umbraco.org/projects - http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/deli.png - - - - - - -/umbraco/ -http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/umbraco.png - - - - -http://our.umbraco.org/ -http://cdn.umbraco.org/cdn/umbraco/images/webmatrix/community.png - - - - - - - install - - - web.config - - - bin - - - umbraco - - - umbraco_client - - - - diff --git a/src/WebPi/installSQL1.sql b/src/WebPi/installSQL1.sql deleted file mode 100644 index 8abdf52c43..0000000000 --- a/src/WebPi/installSQL1.sql +++ /dev/null @@ -1,9 +0,0 @@ -/**********************************************************************/ -/* Install.SQL */ -/* Creates a login and makes the user a member of db roles */ -/* */ -/* Modifications for SQL AZURE - ON MASTER */ -/**********************************************************************/ - - -CREATE LOGIN PlaceHolderForUser WITH PASSWORD = 'PlaceHolderForPassword' \ No newline at end of file diff --git a/src/WebPi/installSQL2.sql b/src/WebPi/installSQL2.sql deleted file mode 100644 index 653d7d4fbf..0000000000 --- a/src/WebPi/installSQL2.sql +++ /dev/null @@ -1,15 +0,0 @@ -/**********************************************************************/ -/* CreateUser.SQL */ -/* Creates a user and makes the user a member of db roles */ -/* This script runs against the User database and requires connection string */ -/* Supports SQL Server and SQL AZURE */ -/**********************************************************************/ - --- Create database user and map to login --- and add user to the datareader, datawriter, ddladmin and securityadmin roles --- - -CREATE USER PlaceHolderForUser FOR LOGIN PlaceHolderForUser; -GO -EXEC sp_addrolemember 'db_owner', PlaceHolderForUser; -GO diff --git a/src/WebPi/manifest.xml b/src/WebPi/manifest.xml deleted file mode 100644 index cfd5740051..0000000000 --- a/src/WebPi/manifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/WebPi/parameters.xml b/src/WebPi/parameters.xml deleted file mode 100644 index 350520407d..0000000000 --- a/src/WebPi/parameters.xml +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/WebPi/umbraco/favicon.ico b/src/WebPi/umbraco/favicon.ico deleted file mode 100644 index e3ea01cf106dd2262f1941b8478a9fe69b31619a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15934 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 N*pF-&?bpVB{uj2nDQy4% diff --git a/tools/contributing/clonefork.png b/tools/contributing/clonefork.png new file mode 100644 index 0000000000000000000000000000000000000000..ef4e4f801d582fc3f633fca14404b2dea97a07e6 GIT binary patch literal 12248 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≪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 M&n)=95?C;9I2mLm$|MXup@?XT1F*f8saPj|f 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 G&;JJ={oiZ= literal 0 HcmV?d00001 diff --git a/tools/contributing/defaultbranch.png b/tools/contributing/defaultbranch.png new file mode 100644 index 0000000000000000000000000000000000000000..0595e0b4eae02db9cf8c983144cf28ef6fa5e474 GIT binary patch literal 18754 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

pSs22`c61PDK{>yQmKmOAm@nZb}_#w+iA82= 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 bAk_a4bNw63&6@1o00000NkvXXu0mjfE77Oi literal 0 HcmV?d00001 diff --git a/tools/contributing/forkrepository.png b/tools/contributing/forkrepository.png new file mode 100644 index 0000000000000000000000000000000000000000..30195ec247311dec2bf8697e3322472785da54af GIT binary patch literal 12700 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 btCpZS+2*~iUD Date: Tue, 20 Mar 2018 15:48:18 +0100 Subject: [PATCH 02/15] Port v7@2aa0dfb2c5 - WIP --- .../Auditing/AuditEventHandler.cs | 351 ++++++++++++++++++ .../Auditing/IdentityAuditEventArgs.cs | 54 ++- .../Cache/HttpRequestCacheProvider.cs | 2 +- .../Collections/CompositeTypeTypeKey.cs | 61 +++ src/Umbraco.Core/Collections/TypeList.cs | 33 ++ src/Umbraco.Web/UI/Pages/ClientTools.cs | 18 +- 6 files changed, 505 insertions(+), 14 deletions(-) create mode 100644 src/Umbraco.Core/Auditing/AuditEventHandler.cs create mode 100644 src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs create mode 100644 src/Umbraco.Core/Collections/TypeList.cs diff --git a/src/Umbraco.Core/Auditing/AuditEventHandler.cs b/src/Umbraco.Core/Auditing/AuditEventHandler.cs new file mode 100644 index 0000000000..9b38d5b59e --- /dev/null +++ b/src/Umbraco.Core/Auditing/AuditEventHandler.cs @@ -0,0 +1,351 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Web; +using Umbraco.Core.Events; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Membership; +using Umbraco.Core.Security; +using Umbraco.Core.Services; + +namespace Umbraco.Core.Auditing +{ + public sealed class AuditEventHandler : ApplicationEventHandler + { + private IAuditService _auditServiceInstance; + private IUserService _userServiceInstance; + private IEntityService _entityServiceInstance; + + private IUser CurrentPerformingUser + { + get + { + var identity = Thread.CurrentPrincipal?.GetUmbracoIdentity(); + return identity == null + ? new User { Id = 0, Name = "SYSTEM", Email = "" } + : _userServiceInstance.GetUserById(Convert.ToInt32(identity.Id)); + } + } + + private IUser GetPerformingUser(int userId) + { + var found = userId >= 0 ? _userServiceInstance.GetUserById(userId) : null; + return found ?? new User {Id = 0, Name = "SYSTEM", Email = ""}; + } + + private string PerformingIp + { + get + { + var httpContext = HttpContext.Current == null ? (HttpContextBase) null : new HttpContextWrapper(HttpContext.Current); + var ip = httpContext.GetCurrentRequestIpAddress(); + if (ip.ToLowerInvariant().StartsWith("unknown")) ip = ""; + return ip; + } + } + + protected override void ApplicationStarted(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext) + { + _auditServiceInstance = applicationContext.Services.AuditService; + _userServiceInstance = applicationContext.Services.UserService; + _entityServiceInstance = applicationContext.Services.EntityService; + + //BackOfficeUserManager.AccountLocked += ; + //BackOfficeUserManager.AccountUnlocked += ; + BackOfficeUserManager.ForgotPasswordRequested += OnForgotPasswordRequest; + BackOfficeUserManager.ForgotPasswordChangedSuccess += OnForgotPasswordChange; + BackOfficeUserManager.LoginFailed += OnLoginFailed; + //BackOfficeUserManager.LoginRequiresVerification += ; + BackOfficeUserManager.LoginSuccess += OnLoginSuccess; + BackOfficeUserManager.LogoutSuccess += OnLogoutSuccess; + BackOfficeUserManager.PasswordChanged += OnPasswordChanged; + BackOfficeUserManager.PasswordReset += OnPasswordReset; + //BackOfficeUserManager.ResetAccessFailedCount += ; + + UserService.SavedUserGroup2 += OnSavedUserGroupWithUsers; + + UserService.SavedUser += OnSavedUser; + UserService.DeletedUser += OnDeletedUser; + UserService.UserGroupPermissionsAssigned += UserGroupPermissionAssigned; + + MemberService.Saved += OnSavedMember; + MemberService.Deleted += OnDeletedMember; + MemberService.AssignedRoles += OnAssignedRoles; + MemberService.RemovedRoles += OnRemovedRoles; + MemberService.Exported += OnMemberExported; + } + + private string FormatEmail(IMember member) + { + return member == null ? string.Empty : member.Email.IsNullOrWhiteSpace() ? "" : $"<{member.Email}>"; + } + + private string FormatEmail(IUser user) + { + return user == null ? string.Empty : user.Email.IsNullOrWhiteSpace() ? "" : $"<{user.Email}>"; + } + + private void OnRemovedRoles(IMemberService sender, RolesEventArgs args) + { + var performingUser = CurrentPerformingUser; + var roles = string.Join(", ", args.Roles); + var members = sender.GetAllMembers(args.MemberIds).ToDictionary(x => x.Id, x => x); + foreach (var id in args.MemberIds) + { + members.TryGetValue(id, out var member); + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + -1, $"Member {id} \"{member?.Name ?? "(unknown)"}\" {FormatEmail(member)}", + "umbraco/member/roles/removed", $"roles modified, removed {roles}"); + } + } + + private void OnAssignedRoles(IMemberService sender, RolesEventArgs args) + { + var performingUser = CurrentPerformingUser; + var roles = string.Join(", ", args.Roles); + var members = sender.GetAllMembers(args.MemberIds).ToDictionary(x => x.Id, x => x); + foreach (var id in args.MemberIds) + { + members.TryGetValue(id, out var member); + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + -1, $"Member {id} \"{member?.Name ?? "(unknown)"}\" {FormatEmail(member)}", + "umbraco/member/roles/assigned", $"roles modified, assigned {roles}"); + } + } + + private void OnMemberExported(IMemberService sender, ExportedMemberEventArgs exportedMemberEventArgs) + { + var performingUser = CurrentPerformingUser; + var member = exportedMemberEventArgs.Member; + + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + -1, $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", + "umbraco/member/exported", "exported member data"); + } + + private void OnSavedUserGroupWithUsers(IUserService sender, SaveEventArgs saveEventArgs) + { + var performingUser = CurrentPerformingUser; + foreach (var groupWithUser in saveEventArgs.SavedEntities) + { + var group = groupWithUser.UserGroup; + + var dp = string.Join(", ", ((UserGroup)group).GetPreviouslyDirtyProperties()); + var sections = ((UserGroup)group).WasPropertyDirty("AllowedSections") + ? string.Join(", ", group.AllowedSections) + : null; + var perms = ((UserGroup)group).WasPropertyDirty("Permissions") + ? string.Join(", ", group.Permissions) + : null; + + var sb = new StringBuilder(); + sb.Append($"updating {(string.IsNullOrWhiteSpace(dp) ? "(nothing)" : dp)};"); + if (sections != null) + sb.Append($", assigned sections: {sections}"); + if (perms != null) + { + if (sections != null) + sb.Append(", "); + sb.Append($"default perms: {perms}"); + } + + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + -1, $"User Group {group.Id} \"{group.Name}\" ({group.Alias})", + "umbraco/user-group/save", $"{sb}"); + + // now audit the users that have changed + + foreach (var user in groupWithUser.RemovedUsers) + { + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + user.Id, $"User \"{user.Name}\" {FormatEmail(user)}", + "umbraco/user-group/save", $"Removed user \"{user.Name}\" {FormatEmail(user)} from group {group.Id} \"{group.Name}\" ({group.Alias})"); + } + + foreach (var user in groupWithUser.AddedUsers) + { + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + user.Id, $"User \"{user.Name}\" {FormatEmail(user)}", + "umbraco/user-group/save", $"Added user \"{user.Name}\" {FormatEmail(user)} to group {group.Id} \"{group.Name}\" ({group.Alias})"); + } + } + } + + private void UserGroupPermissionAssigned(IUserService sender, SaveEventArgs saveEventArgs) + { + var performingUser = CurrentPerformingUser; + var perms = saveEventArgs.SavedEntities; + foreach (var perm in perms) + { + var group = sender.GetUserGroupById(perm.UserGroupId); + var assigned = string.Join(", ", perm.AssignedPermissions); + var entity = _entityServiceInstance.Get(perm.EntityId); + + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + -1, $"User Group {group.Id} \"{group.Name}\" ({group.Alias})", + "umbraco/user-group/permissions-change", $"assigning {(string.IsNullOrWhiteSpace(assigned) ? "(nothing)" : assigned)} on id:{perm.EntityId} \"{entity.Name}\""); + } + } + + private void OnSavedMember(IMemberService sender, SaveEventArgs saveEventArgs) + { + var performingUser = CurrentPerformingUser; + var members = saveEventArgs.SavedEntities; + foreach (var member in members) + { + var dp = string.Join(", ", ((Member) member).GetPreviouslyDirtyProperties()); + + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + -1, $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", + "umbraco/member/save", $"updating {(string.IsNullOrWhiteSpace(dp) ? "(nothing)" : dp)}"); + } + } + + private void OnDeletedMember(IMemberService sender, DeleteEventArgs deleteEventArgs) + { + var performingUser = CurrentPerformingUser; + var members = deleteEventArgs.DeletedEntities; + foreach (var member in members) + { + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + -1, $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", + "umbraco/member/delete", $"delete member id:{member.Id} \"{member.Name}\" {FormatEmail(member)}"); + } + } + + private void OnSavedUser(IUserService sender, SaveEventArgs saveEventArgs) + { + var performingUser = CurrentPerformingUser; + var affectedUsers = saveEventArgs.SavedEntities; + foreach (var affectedUser in affectedUsers) + { + var groups = affectedUser.WasPropertyDirty("Groups") + ? string.Join(", ", affectedUser.Groups.Select(x => x.Alias)) + : null; + + var dp = string.Join(", ", ((User)affectedUser).GetPreviouslyDirtyProperties()); + + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + affectedUser.Id, $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}", + "umbraco/user/save", $"updating {(string.IsNullOrWhiteSpace(dp) ? "(nothing)" : dp)}{(groups == null ? "" : "; groups assigned: " + groups)}"); + } + } + + private void OnDeletedUser(IUserService sender, DeleteEventArgs deleteEventArgs) + { + var performingUser = CurrentPerformingUser; + var affectedUsers = deleteEventArgs.DeletedEntities; + foreach (var affectedUser in affectedUsers) + _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + DateTime.UtcNow, + affectedUser.Id, $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}", + "umbraco/user/delete", "delete user"); + } + + private void OnLoginSuccess(object sender, EventArgs args) + { + if (args is IdentityAuditEventArgs identityArgs) + { + var performingUser = GetPerformingUser(identityArgs.PerformingUser); + WriteAudit(performingUser, identityArgs.AffectedUser, identityArgs.IpAddress, "umbraco/user/sign-in/login", "login success"); + } + } + + private void OnLogoutSuccess(object sender, EventArgs args) + { + if (args is IdentityAuditEventArgs identityArgs) + { + var performingUser = GetPerformingUser(identityArgs.PerformingUser); + WriteAudit(performingUser, identityArgs.AffectedUser, identityArgs.IpAddress, "umbraco/user/sign-in/logout", "logout success"); + } + } + + private void OnPasswordReset(object sender, EventArgs args) + { + if (args is IdentityAuditEventArgs identityArgs && identityArgs.PerformingUser >= 0) + { + WriteAudit(identityArgs.PerformingUser, identityArgs.AffectedUser, identityArgs.IpAddress, "umbraco/user/password/reset", "password reset"); + } + } + + private void OnPasswordChanged(object sender, EventArgs args) + { + if (args is IdentityAuditEventArgs identityArgs && identityArgs.PerformingUser >= 0) + { + WriteAudit(identityArgs.PerformingUser, identityArgs.AffectedUser, identityArgs.IpAddress, "umbraco/user/password/change", "password change"); + } + } + + private void OnLoginFailed(object sender, EventArgs args) + { + if (args is IdentityAuditEventArgs identityArgs && identityArgs.PerformingUser >= 0) + { + WriteAudit(identityArgs.PerformingUser, 0, identityArgs.IpAddress, "umbraco/user/sign-in/failed", "login failed", affectedDetails: ""); + } + } + + private void OnForgotPasswordChange(object sender, EventArgs args) + { + if (args is IdentityAuditEventArgs identityArgs && identityArgs.PerformingUser >= 0) + { + WriteAudit(identityArgs.PerformingUser, identityArgs.AffectedUser, identityArgs.IpAddress, "umbraco/user/password/forgot/change", "password forgot/change"); + } + } + + private void OnForgotPasswordRequest(object sender, EventArgs args) + { + if (args is IdentityAuditEventArgs identityArgs && identityArgs.PerformingUser >= 0) + { + WriteAudit(identityArgs.PerformingUser, identityArgs.AffectedUser, identityArgs.IpAddress, "umbraco/user/password/forgot/request", "password forgot/request"); + } + } + + private void WriteAudit(int performingId, int affectedId, string ipAddress, string eventType, string eventDetails, string affectedDetails = null) + { + var performingUser = _userServiceInstance.GetUserById(performingId); + + var performingDetails = performingUser == null + ? $"User UNKNOWN:{performingId}" + : $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}"; + + WriteAudit(performingId, performingDetails, affectedId, ipAddress, eventType, eventDetails, affectedDetails); + } + + private void WriteAudit(IUser performingUser, int affectedId, string ipAddress, string eventType, string eventDetails) + { + var performingDetails = performingUser == null + ? $"User UNKNOWN" + : $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}"; + + WriteAudit(performingUser?.Id ?? 0, performingDetails, affectedId, ipAddress, eventType, eventDetails); + } + + private void WriteAudit(int performingId, string performingDetails, int affectedId, string ipAddress, string eventType, string eventDetails, string affectedDetails = null) + { + if (affectedDetails == null) + { + var affectedUser = _userServiceInstance.GetUserById(affectedId); + affectedDetails = affectedUser == null + ? $"User UNKNOWN:{affectedId}" + : $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}"; + } + + _auditServiceInstance.Write(performingId, performingDetails, + ipAddress, + DateTime.UtcNow, + affectedId, affectedDetails, + eventType, eventDetails); + } + } +} diff --git a/src/Umbraco.Core/Auditing/IdentityAuditEventArgs.cs b/src/Umbraco.Core/Auditing/IdentityAuditEventArgs.cs index 14445d461f..c58bb409b0 100644 --- a/src/Umbraco.Core/Auditing/IdentityAuditEventArgs.cs +++ b/src/Umbraco.Core/Auditing/IdentityAuditEventArgs.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Threading; using System.Web; using Umbraco.Core.Security; @@ -45,12 +46,8 @@ namespace Umbraco.Core.Auditing /// public string Username { get; private set; } - ///

- /// Sets the properties on the event being raised, all parameters are optional except for the action being performed - /// - /// An action based on the AuditEvent enum - /// The client's IP address. This is usually automatically set but could be overridden if necessary - /// The Id of the user performing the action (if different from the user affected by the action) + [Obsolete("Use the method that has the affectedUser parameter instead")] + [EditorBrowsable(EditorBrowsableState.Never)] public IdentityAuditEventArgs(AuditEvent action, string ipAddress, int performingUser = -1) { DateTimeUtc = DateTime.UtcNow; @@ -63,6 +60,35 @@ namespace Umbraco.Core.Auditing : performingUser; } + /// + /// Default constructor + /// + /// + /// + /// + /// + /// + public IdentityAuditEventArgs(AuditEvent action, string ipAddress, string comment = null, int performingUser = -1, int affectedUser = -1) + { + DateTimeUtc = DateTime.UtcNow; + Action = action; + + IpAddress = ipAddress; + Comment = comment; + AffectedUser = affectedUser; + + PerformingUser = performingUser == -1 + ? GetCurrentRequestBackofficeUserId() + : performingUser; + } + + /// + /// Creates an instance without a performing or affected user (the id will be set to -1) + /// + /// + /// + /// + /// public IdentityAuditEventArgs(AuditEvent action, string ipAddress, string username, string comment) { DateTimeUtc = DateTime.UtcNow; @@ -71,6 +97,22 @@ namespace Umbraco.Core.Auditing IpAddress = ipAddress; Username = username; Comment = comment; + + PerformingUser = -1; + } + + public IdentityAuditEventArgs(AuditEvent action, string ipAddress, string username, string comment, int performingUser) + { + DateTimeUtc = DateTime.UtcNow; + Action = action; + + IpAddress = ipAddress; + Username = username; + Comment = comment; + + PerformingUser = performingUser == -1 + ? GetCurrentRequestBackofficeUserId() + : performingUser; } /// diff --git a/src/Umbraco.Core/Cache/HttpRequestCacheProvider.cs b/src/Umbraco.Core/Cache/HttpRequestCacheProvider.cs index 4a43dd154f..6f97651042 100644 --- a/src/Umbraco.Core/Cache/HttpRequestCacheProvider.cs +++ b/src/Umbraco.Core/Cache/HttpRequestCacheProvider.cs @@ -145,7 +145,7 @@ namespace Umbraco.Core.Cache #region Insert #endregion - private class NoopLocker : DisposableObject + private class NoopLocker : DisposableObjectSlim { protected override void DisposeResources() { } diff --git a/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs b/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs new file mode 100644 index 0000000000..1a4e7ae1a9 --- /dev/null +++ b/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs @@ -0,0 +1,61 @@ +using System; + +namespace Umbraco.Core.Collections +{ + /// + /// Represents a composite key of (Type, Type) for fast dictionaries. + /// + internal struct CompositeTypeTypeKey : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + public CompositeTypeTypeKey(Type type1, Type type2) : this() + { + Type1 = type1; + Type2 = type2; + } + + /// + /// Gets the first type. + /// + public Type Type1 { get; private set; } + + /// + /// Gets the second type. + /// + public Type Type2 { get; private set; } + + /// + public bool Equals(CompositeTypeTypeKey other) + { + return Type1 == other.Type1 && Type2 == other.Type2; + } + + /// + public override bool Equals(object obj) + { + var other = obj is CompositeTypeTypeKey ? (CompositeTypeTypeKey)obj : default(CompositeTypeTypeKey); + return Type1 == other.Type1 && Type2 == other.Type2; + } + + public static bool operator ==(CompositeTypeTypeKey key1, CompositeTypeTypeKey key2) + { + return key1.Type1 == key2.Type1 && key1.Type2 == key2.Type2; + } + + public static bool operator !=(CompositeTypeTypeKey key1, CompositeTypeTypeKey key2) + { + return key1.Type1 != key2.Type1 || key1.Type2 != key2.Type2; + } + + /// + public override int GetHashCode() + { + unchecked + { + return (Type1.GetHashCode() * 397) ^ Type2.GetHashCode(); + } + } + } +} diff --git a/src/Umbraco.Core/Collections/TypeList.cs b/src/Umbraco.Core/Collections/TypeList.cs new file mode 100644 index 0000000000..37ca427ba1 --- /dev/null +++ b/src/Umbraco.Core/Collections/TypeList.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Umbraco.Core.Collections +{ + /// + /// Represents a list of types. + /// + /// Types in the list are, or derive from, or implement, the base type. + /// The base type. + internal class TypeList + { + private readonly List _list = new List(); + + /// + /// Adds a type to the list. + /// + /// The type to add. + public void Add() + where T : TBase + { + _list.Add(typeof(T)); + } + + /// + /// Determines whether a type is in the list. + /// + public bool Contains(Type type) + { + return _list.Contains(type); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/UI/Pages/ClientTools.cs b/src/Umbraco.Web/UI/Pages/ClientTools.cs index 0cd59591be..7b59725101 100644 --- a/src/Umbraco.Web/UI/Pages/ClientTools.cs +++ b/src/Umbraco.Web/UI/Pages/ClientTools.cs @@ -46,7 +46,8 @@ namespace Umbraco.Web.UI.Pages { return string.Format(ClientMgrScript + ".reloadContentFrameUrlIfPathLoaded('{0}');", url); } - public static string ReloadLocation { get { return string.Format(ClientMgrScript + ".reloadLocation();"); } } + public static string ReloadLocation { get { return ClientMgrScript + ".reloadLocation();"; } } + public static string ReloadLocationIfMatched { get { return ClientMgrScript + ".reloadLocation('{0}');"; } } public static string ChildNodeCreated = GetMainTree + ".childNodeCreated();"; public static string SyncTree { get { return GetMainTree + ".syncTree('{0}', {1});"; } } public static string ClearTreeCache { get { return GetMainTree + ".clearTreeCache();"; } } @@ -168,16 +169,19 @@ namespace Umbraco.Web.UI.Pages return this; } - /// - /// Reloads location, refreshing what is in the content frame - /// - public ClientTools ReloadLocation() + public ClientTools ReloadLocationIfMatched(string routePath) { - RegisterClientScript(Scripts.ReloadLocation); - + RegisterClientScript(string.Format(Scripts.ReloadLocationIfMatched, routePath)); return this; } + public ClientTools ReloadLocation() + { + RegisterClientScript(Scripts.ReloadLocation); + return this; + } + + private string EnsureUmbracoUrl(string url) { if (url.StartsWith("/") && url.StartsWith(IOHelper.ResolveUrl(SystemDirectories.Umbraco)) == false) From a9147f14730c1a835f4b7949d9654cc698bb5423 Mon Sep 17 00:00:00 2001 From: Stephan Date: Tue, 20 Mar 2018 18:21:37 +0100 Subject: [PATCH 03/15] Port v7@2aa0dfb2c5 - WIP --- src/Umbraco.Core/Composing/TypeFinder.cs | 52 ++++++++- src/Umbraco.Core/Composing/TypeLoader.cs | 106 +++++++++++------- .../ClientDependencyConfiguration.cs | 68 +++++++++++ .../Configuration/ContentXmlStorage.cs | 9 -- .../Configuration/GlobalSettings.cs | 26 ++--- .../Configuration/LocalTempStorage.cs | 9 ++ .../UmbracoSettings/BackOfficeElement.cs | 18 +++ .../UmbracoSettings/IBackOfficeSection.cs | 7 ++ .../UmbracoSettings/ITourSection.cs | 7 ++ .../IUmbracoSettingsSection.cs | 2 + .../UmbracoSettings/TourConfigElement.cs | 17 +++ .../UmbracoSettings/UmbracoSettingsSection.cs | 11 ++ src/Umbraco.Core/Constants-Applications.cs | 5 + src/Umbraco.Core/Constants-PropertyEditors.cs | 5 + src/Umbraco.Core/Constants-Security.cs | 1 + src/Umbraco.Core/Constants-System.cs | 36 ++++++ src/Umbraco.Core/DateTimeExtensions.cs | 4 +- src/Umbraco.Core/DisposableObject.cs | 3 + src/Umbraco.Core/DisposableObjectSlim.cs | 53 +++++++++ src/Umbraco.Core/DisposableTimer.cs | 2 +- src/Umbraco.Core/HashGenerator.cs | 2 +- src/Umbraco.Core/HttpContextExtensions.cs | 4 +- src/Umbraco.Core/IO/IOHelper.cs | 12 ++ src/Umbraco.Core/IO/PhysicalFileSystem.cs | 40 ++++++- src/Umbraco.Core/IO/SystemFiles.cs | 10 +- src/Umbraco.Core/Manifest/ManifestWatcher.cs | 2 +- src/Umbraco.Core/Scoping/ScopeContext.cs | 24 +++- .../Strings/DefaultShortStringHelper.cs | 18 ++- src/Umbraco.Core/UdiEntityType.cs | 9 +- src/Umbraco.Core/Umbraco.Core.csproj | 7 +- src/Umbraco.Core/UriExtensions.cs | 17 ++- 31 files changed, 479 insertions(+), 107 deletions(-) delete mode 100644 src/Umbraco.Core/Configuration/ContentXmlStorage.cs create mode 100644 src/Umbraco.Core/Configuration/LocalTempStorage.cs create mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/BackOfficeElement.cs create mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/IBackOfficeSection.cs create mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/ITourSection.cs create mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/TourConfigElement.cs create mode 100644 src/Umbraco.Core/DisposableObjectSlim.cs diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index ca7c642453..f244d1d1ce 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Configuration; using System.IO; using System.Linq; using System.Reflection; @@ -21,6 +22,38 @@ namespace Umbraco.Core.Composing { private static volatile HashSet _localFilteredAssemblyCache; private static readonly object LocalFilteredAssemblyCacheLocker = new object(); + private static readonly List NotifiedLoadExceptionAssemblies = new List(); + private static string[] _assembliesAcceptingLoadExceptions; + + private static string[] AssembliesAcceptingLoadExceptions + { + get + { + if (_assembliesAcceptingLoadExceptions != null) + return _assembliesAcceptingLoadExceptions; + + var s = ConfigurationManager.AppSettings["Umbraco.AssembliesAcceptingLoadExceptions"]; + return _assembliesAcceptingLoadExceptions = string.IsNullOrWhiteSpace(s) + ? Array.Empty() + : s.Split(',').Select(x => x.Trim()).ToArray(); + } + } + + private static bool AcceptsLoadExceptions(Assembly a) + { + if (AssembliesAcceptingLoadExceptions.Length == 0) + return false; + if (AssembliesAcceptingLoadExceptions.Length == 1 && AssembliesAcceptingLoadExceptions[0] == "*") + return true; + var name = a.GetName().Name; // simple name of the assembly + return AssembliesAcceptingLoadExceptions.Any(pattern => + { + if (pattern.Length > name.Length) return false; // pattern longer than name + if (pattern.Length == name.Length) return pattern.InvariantEquals(name); // same length, must be identical + if (pattern[pattern.Length] != '.') return false; // pattern is shorter than name, must end with dot + return name.StartsWith(pattern); // and name must start with pattern + }); + } /// /// lazily load a reference to all assemblies and only local assemblies. @@ -45,7 +78,7 @@ namespace Umbraco.Core.Composing HashSet assemblies = null; try { - var isHosted = HttpContext.Current != null || HostingEnvironment.IsHosted; + var isHosted = IOHelper.IsHosted; try { @@ -529,8 +562,21 @@ namespace Umbraco.Core.Composing foreach (var loaderException in rex.LoaderExceptions.WhereNotNull()) AppendLoaderException(sb, loaderException); - // rethrow with new message - throw new ReflectionTypeLoadException(rex.Types, rex.LoaderExceptions, sb.ToString()); + var ex = new ReflectionTypeLoadException(rex.Types, rex.LoaderExceptions, sb.ToString()); + + // rethrow with new message, unless accepted + if (AcceptsLoadExceptions(a) == false) throw ex; + + // log a warning, and return what we can + lock (NotifiedLoadExceptionAssemblies) + { + if (NotifiedLoadExceptionAssemblies.Contains(a.FullName) == false) + { + NotifiedLoadExceptionAssemblies.Add(a.FullName); + Current.Logger.Warn(typeof (TypeFinder), ex, $"Could not load all types from {a.GetName().Name}."); + } + } + return rex.Types.WhereNotNull().ToArray(); } } diff --git a/src/Umbraco.Core/Composing/TypeLoader.cs b/src/Umbraco.Core/Composing/TypeLoader.cs index 02c69ceec8..1e62c68c4b 100644 --- a/src/Umbraco.Core/Composing/TypeLoader.cs +++ b/src/Umbraco.Core/Composing/TypeLoader.cs @@ -5,8 +5,10 @@ using System.Linq; using System.Reflection; using System.Text; using System.Threading; +using System.Web; using System.Web.Compilation; using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; using Umbraco.Core.IO; using Umbraco.Core.Logging; using File = System.IO.File; @@ -28,11 +30,13 @@ namespace Umbraco.Core.Composing private readonly IRuntimeCacheProvider _runtimeCache; private readonly ProfilingLogger _logger; - private readonly string _tempFolder; private readonly object _typesLock = new object(); private readonly Dictionary _types = new Dictionary(); + private readonly Lazy _typesListFilePath = new Lazy(GetTypesListFilePath); + private readonly Lazy _typesHashFilePath = new Lazy(GetTypesHashFilePath); + private string _cachedAssembliesHash; private string _currentAssembliesHash; private IEnumerable _assemblies; @@ -49,13 +53,6 @@ namespace Umbraco.Core.Composing _runtimeCache = runtimeCache ?? throw new ArgumentNullException(nameof(runtimeCache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - // the temp folder where the cache file lives - _tempFolder = IOHelper.MapPath("~/App_Data/TEMP/TypesCache"); - if (Directory.Exists(_tempFolder) == false) - Directory.CreateDirectory(_tempFolder); - - var typesListFile = GeTypesListFilePath(); - if (detectChanges) { //first check if the cached hash is string.Empty, if it is then we need @@ -67,7 +64,8 @@ namespace Umbraco.Core.Composing // if the hash has changed, clear out the persisted list no matter what, this will force // rescanning of all types including lazy ones. // http://issues.umbraco.org/issue/U4-4789 - File.Delete(typesListFile); + if (File.Exists(TypesListFilePath)) + File.Delete(TypesListFilePath); WriteCacheTypesHash(); } @@ -77,13 +75,24 @@ namespace Umbraco.Core.Composing // if the hash has changed, clear out the persisted list no matter what, this will force // rescanning of all types including lazy ones. // http://issues.umbraco.org/issue/U4-4789 - File.Delete(typesListFile); + if (File.Exists(TypesListFilePath)) + File.Delete(TypesListFilePath); // always set to true if we're not detecting (generally only for testing) RequiresRescanning = true; } } + /// + /// Gets the plugin list file path. + /// + private string TypesListFilePath => _typesListFilePath.Value; + + /// + /// Gets the plugin hash file path. + /// + private string TypesHashFilePath => _typesHashFilePath.Value; + /// /// Gets or sets the set of assemblies to scan. /// @@ -135,10 +144,9 @@ namespace Umbraco.Core.Composing if (_cachedAssembliesHash != null) return _cachedAssembliesHash; - var filePath = GetTypesHashFilePath(); - if (File.Exists(filePath) == false) return string.Empty; + if (!File.Exists(TypesHashFilePath)) return string.Empty; - var hash = File.ReadAllText(filePath, Encoding.UTF8); + var hash = File.ReadAllText(TypesHashFilePath, Encoding.UTF8); _cachedAssembliesHash = hash; return _cachedAssembliesHash; @@ -177,8 +185,7 @@ namespace Umbraco.Core.Composing /// private void WriteCacheTypesHash() { - var filePath = GetTypesHashFilePath(); - File.WriteAllText(filePath, CurrentAssembliesHash, Encoding.UTF8); + File.WriteAllText(TypesHashFilePath, CurrentAssembliesHash, Encoding.UTF8); } /// @@ -295,8 +302,7 @@ namespace Umbraco.Core.Composing { try { - var filePath = GeTypesListFilePath(); - File.Delete(filePath); + File.Delete(TypesListFilePath); } catch { @@ -312,11 +318,10 @@ namespace Umbraco.Core.Composing { var cache = new Dictionary, IEnumerable>(); - var filePath = GeTypesListFilePath(); - if (File.Exists(filePath) == false) + if (File.Exists(TypesListFilePath) == false) return cache; - using (var stream = GetFileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, ListFileOpenReadTimeout)) + using (var stream = GetFileStream(TypesListFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, ListFileOpenReadTimeout)) using (var reader = new StreamReader(stream)) { while (true) @@ -354,28 +359,47 @@ namespace Umbraco.Core.Composing } // internal for tests - internal string GeTypesListFilePath() - { - var filename = "umbraco-types." + NetworkHelper.FileSafeMachineName + ".list"; - return Path.Combine(_tempFolder, filename); - } + internal static string GetTypesListFilePath() => GetFilePath("list"); - private string GetTypesHashFilePath() + private static string GetTypesHashFilePath() => GetFilePath("hash"); + + private static string GetFilePath(string extension) { - var filename = "umbraco-types." + NetworkHelper.FileSafeMachineName + ".hash"; - return Path.Combine(_tempFolder, filename); + string path; + switch (GlobalSettings.LocalTempStorageLocation) + { + case LocalTempStorage.AspNetTemp: + path = Path.Combine(HttpRuntime.CodegenDir, "UmbracoData", "umbraco-types." + extension); + break; + case LocalTempStorage.EnvironmentTemp: + // include the appdomain hash is just a safety check, for example if a website is moved from worker A to worker B and then back + // to worker A again, in theory the %temp% folder should already be empty but we really want to make sure that its not + // utilizing an old path - assuming we cannot have SHA1 collisions on AppDomainAppId + var appDomainHash = HttpRuntime.AppDomainAppId.ToSHA1(); + var cachePath = Path.Combine(Environment.ExpandEnvironmentVariables("%temp%"), "UmbracoData", appDomainHash); + path = Path.Combine(cachePath, "umbraco-types." + extension); + break; + case LocalTempStorage.Default: + default: + var tempFolder = IOHelper.MapPath("~/App_Data/TEMP/TypesCache"); + path = Path.Combine(tempFolder, "umbraco-types." + NetworkHelper.FileSafeMachineName + "." + extension); + break; + } + + // ensure that the folder exists + var directory = Path.GetDirectoryName(path); + if (directory == null) + throw new InvalidOperationException($"Could not determine folder for file \"{path}\"."); + if (Directory.Exists(directory) == false) + Directory.CreateDirectory(directory); + + return path; } // internal for tests internal void WriteCache() { - // be absolutely sure - if (Directory.Exists(_tempFolder) == false) - Directory.CreateDirectory(_tempFolder); - - var filePath = GeTypesListFilePath(); - - using (var stream = GetFileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, ListFileOpenWriteTimeout)) + using (var stream = GetFileStream(TypesListFilePath, FileMode.Create, FileAccess.Write, FileShare.None, ListFileOpenWriteTimeout)) using (var writer = new StreamWriter(stream)) { foreach (var typeList in _types.Values) @@ -405,13 +429,11 @@ namespace Umbraco.Core.Composing /// Generally only used for resetting cache, for example during the install process. public void ClearTypesCache() { - var path = GeTypesListFilePath(); - if (File.Exists(path)) - File.Delete(path); + if (File.Exists(TypesListFilePath)) + File.Delete(TypesListFilePath); - path = GetTypesHashFilePath(); - if (File.Exists(path)) - File.Delete(path); + if (File.Exists(TypesHashFilePath)) + File.Delete(TypesHashFilePath); _runtimeCache.ClearCacheItem(CacheKey); } @@ -589,7 +611,7 @@ namespace Umbraco.Core.Composing // else proceed, typeList = new TypeList(baseType, attributeType); - var scan = RequiresRescanning || File.Exists(GeTypesListFilePath()) == false; + var scan = RequiresRescanning || File.Exists(TypesListFilePath) == false; if (scan) { diff --git a/src/Umbraco.Core/Configuration/ClientDependencyConfiguration.cs b/src/Umbraco.Core/Configuration/ClientDependencyConfiguration.cs index c85df0be73..8693f2e6e8 100644 --- a/src/Umbraco.Core/Configuration/ClientDependencyConfiguration.cs +++ b/src/Umbraco.Core/Configuration/ClientDependencyConfiguration.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Web; using System.Xml.Linq; using ClientDependency.Core.CompositeFiles.Providers; using ClientDependency.Core.Config; +using Semver; using Umbraco.Core.IO; using Umbraco.Core.Logging; @@ -24,10 +28,74 @@ namespace Umbraco.Core.Configuration _logger = logger; _fileName = IOHelper.MapPath(string.Format("{0}/ClientDependency.config", SystemDirectories.Config)); } + + /// + /// Changes the version number in ClientDependency.config to a hashed value for the version and the DateTime.Day + /// + /// The version of Umbraco we're upgrading to + /// A date value to use in the hash to prevent this method from updating the version on each startup + /// Allows the developer to specify the date precision for the hash (i.e. "yyyyMMdd" would be a precision for the day) + /// Boolean to indicate succesful update of the ClientDependency.config file + public bool UpdateVersionNumber(SemVersion version, DateTime date, string dateFormat) + { + var byteContents = Encoding.Unicode.GetBytes(version + date.ToString(dateFormat)); + + //This is a way to convert a string to a long + //see https://www.codeproject.com/Articles/34309/Convert-String-to-bit-Integer + //We could much more easily use MD5 which would create us an INT but since that is not compliant with + //hashing standards we have to use SHA + int intHash; + using (var hash = SHA256.Create()) + { + var bytes = hash.ComputeHash(byteContents); + + var longResult = new[] { 0, 8, 16, 24 } + .Select(i => BitConverter.ToInt64(bytes, i)) + .Aggregate((x, y) => x ^ y); + + //CDF requires an INT, and although this isn't fail safe, it will work for our purposes. We are not hashing for crypto purposes + //so there could be some collisions with this conversion but it's not a problem for our purposes + //It's also important to note that the long.GetHashCode() implementation in .NET is this: return (int) this ^ (int) (this >> 32); + //which means that this value will not change per appdomain like some GetHashCode implementations. + intHash = longResult.GetHashCode(); + } + + try + { + var clientDependencyConfigXml = XDocument.Load(_fileName, LoadOptions.PreserveWhitespace); + if (clientDependencyConfigXml.Root != null) + { + + var versionAttribute = clientDependencyConfigXml.Root.Attribute("version"); + + //Set the new version to the hashcode of now + var oldVersion = versionAttribute.Value; + var newVersion = Math.Abs(intHash).ToString(); + + //don't update if it's the same version + if (oldVersion == newVersion) + return false; + + versionAttribute.SetValue(newVersion); + clientDependencyConfigXml.Save(_fileName, SaveOptions.DisableFormatting); + + _logger.Info(string.Format("Updated version number from {0} to {1}", oldVersion, newVersion)); + return true; + } + } + catch (Exception ex) + { + _logger.Error("Couldn't update ClientDependency version number", ex); + } + + return false; + } /// /// Changes the version number in ClientDependency.config to a random value to avoid stale caches /// + /// + [Obsolete("Use the UpdateVersionNumber method specifying the version, date and dateFormat instead")] public bool IncreaseVersionNumber() { try diff --git a/src/Umbraco.Core/Configuration/ContentXmlStorage.cs b/src/Umbraco.Core/Configuration/ContentXmlStorage.cs deleted file mode 100644 index f81ca8f8cc..0000000000 --- a/src/Umbraco.Core/Configuration/ContentXmlStorage.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Umbraco.Core.Configuration -{ - internal enum ContentXmlStorage - { - Default, - AspNetTemp, - EnvironmentTemp - } -} diff --git a/src/Umbraco.Core/Configuration/GlobalSettings.cs b/src/Umbraco.Core/Configuration/GlobalSettings.cs index 1ef1b1645b..0ed7289b30 100644 --- a/src/Umbraco.Core/Configuration/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/GlobalSettings.cs @@ -470,24 +470,24 @@ namespace Umbraco.Core.Configuration internal static bool ContentCacheXmlStoredInCodeGen { - get { return ContentCacheXmlStorageLocation == ContentXmlStorage.AspNetTemp; } + get { return LocalTempStorageLocation == LocalTempStorage.AspNetTemp; } } - internal static ContentXmlStorage ContentCacheXmlStorageLocation + /// + /// This is the location type to store temporary files such as cache files or other localized files for a given machine + /// + /// + /// Currently used for the xml cache file and the plugin cache files + /// + internal static LocalTempStorage LocalTempStorageLocation { get { - if (ConfigurationManager.AppSettings.ContainsKey("umbracoContentXMLStorage")) - { - return Enum.Parse(ConfigurationManager.AppSettings["umbracoContentXMLStorage"]); - } - if (ConfigurationManager.AppSettings.ContainsKey("umbracoContentXMLUseLocalTemp")) - { - return bool.Parse(ConfigurationManager.AppSettings["umbracoContentXMLUseLocalTemp"]) - ? ContentXmlStorage.AspNetTemp - : ContentXmlStorage.Default; - } - return ContentXmlStorage.Default; + var setting = ConfigurationManager.AppSettings.ContainsKey("umbracoLocalTempStorage"); + if (!string.IsNullOrWhiteSpace(setting)) + return Enum.Parse(setting); + + return LocalTempStorage.Default; } } diff --git a/src/Umbraco.Core/Configuration/LocalTempStorage.cs b/src/Umbraco.Core/Configuration/LocalTempStorage.cs new file mode 100644 index 0000000000..d41f7d1925 --- /dev/null +++ b/src/Umbraco.Core/Configuration/LocalTempStorage.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Core.Configuration +{ + internal enum LocalTempStorage + { + Default, + AspNetTemp, + EnvironmentTemp + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/BackOfficeElement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/BackOfficeElement.cs new file mode 100644 index 0000000000..d1d2a26a96 --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/BackOfficeElement.cs @@ -0,0 +1,18 @@ +using System.Configuration; + +namespace Umbraco.Core.Configuration.UmbracoSettings +{ + internal class BackOfficeElement : UmbracoConfigurationElement, IBackOfficeSection + { + [ConfigurationProperty("tours")] + internal TourConfigElement Tours + { + get { return (TourConfigElement)this["tours"]; } + } + + ITourSection IBackOfficeSection.Tours + { + get { return Tours; } + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IBackOfficeSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IBackOfficeSection.cs new file mode 100644 index 0000000000..36dd6a22ed --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IBackOfficeSection.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Core.Configuration.UmbracoSettings +{ + public interface IBackOfficeSection + { + ITourSection Tours { get; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/ITourSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/ITourSection.cs new file mode 100644 index 0000000000..938642521e --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/ITourSection.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Core.Configuration.UmbracoSettings +{ + public interface ITourSection + { + bool EnableTours { get; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs index 0801c9933f..085a826626 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs @@ -5,6 +5,8 @@ namespace Umbraco.Core.Configuration.UmbracoSettings { public interface IUmbracoSettingsSection : IUmbracoConfigurationSection { + IBackOfficeSection BackOffice { get; } + IContentSection Content { get; } ISecuritySection Security { get; } diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/TourConfigElement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/TourConfigElement.cs new file mode 100644 index 0000000000..ebb649ca3b --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/TourConfigElement.cs @@ -0,0 +1,17 @@ +using System.Configuration; + +namespace Umbraco.Core.Configuration.UmbracoSettings +{ + internal class TourConfigElement : UmbracoConfigurationElement, ITourSection + { + //disabled by default so that upgraders don't get it enabled by default + //TODO: we probably just want to disable the initial one from automatically loading ? + [ConfigurationProperty("enable", DefaultValue = false)] + public bool EnableTours + { + get { return (bool)this["enable"]; } + } + + //TODO: We could have additional filters, etc... defined here + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs index 49a791144b..0cf97b2560 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs @@ -8,6 +8,12 @@ namespace Umbraco.Core.Configuration.UmbracoSettings public class UmbracoSettingsSection : ConfigurationSection, IUmbracoSettingsSection { + [ConfigurationProperty("backOffice")] + internal BackOfficeElement BackOffice + { + get { return (BackOfficeElement)this["backOffice"]; } + } + [ConfigurationProperty("content")] internal ContentElement Content { @@ -132,6 +138,11 @@ namespace Umbraco.Core.Configuration.UmbracoSettings get { return Templates; } } + IBackOfficeSection IUmbracoSettingsSection.BackOffice + { + get { return BackOffice; } + } + IDeveloperSection IUmbracoSettingsSection.Developer { get { return Developer; } diff --git a/src/Umbraco.Core/Constants-Applications.cs b/src/Umbraco.Core/Constants-Applications.cs index 014c4af450..d401eaee88 100644 --- a/src/Umbraco.Core/Constants-Applications.cs +++ b/src/Umbraco.Core/Constants-Applications.cs @@ -73,6 +73,11 @@ /// public const string Media = "media"; + /// + /// alias for the macro tree. + /// + public const string Macros = "macros"; + /// /// alias for the datatype tree. /// diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index f6ce98901d..43bd381571 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -63,6 +63,11 @@ namespace Umbraco.Core /// DropDown List Multiple, Publish Keys. /// public const string DropdownlistMultiplePublishKeys = "Umbraco.DropdownlistMultiplePublishKeys"; + + /// + /// DropDown List. + /// + public const string DropDownListFlexible = "Umbraco.DropDown.Flexible"; /// /// Folder Browser. diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs index 2e9de0d8a4..4c24febb22 100644 --- a/src/Umbraco.Core/Constants-Security.cs +++ b/src/Umbraco.Core/Constants-Security.cs @@ -13,6 +13,7 @@ namespace Umbraco.Core public const int SuperId = -1; public const string AdminGroupAlias = "admin"; + public const string SensitiveDataGroupAlias = "sensitiveData"; public const string TranslatorGroupAlias = "translator"; public const string BackOfficeAuthenticationType = "UmbracoBackOffice"; diff --git a/src/Umbraco.Core/Constants-System.cs b/src/Umbraco.Core/Constants-System.cs index 8ab2f74f4c..9fad62c347 100644 --- a/src/Umbraco.Core/Constants-System.cs +++ b/src/Umbraco.Core/Constants-System.cs @@ -12,16 +12,52 @@ /// public const int Root = -1; + /// + /// The string identifier for global system root node. + /// + /// Use this instead of re-creating the string everywhere. + public const string RootString = "-1"; + /// /// The integer identifier for content's recycle bin. /// public const int RecycleBinContent = -20; + /// + /// The string identifier for content's recycle bin. + /// + /// Use this instead of re-creating the string everywhere. + public const string RecycleBinContentString = "-20"; + + /// + /// The string path prefix of the content's recycle bin. + /// + /// + /// Everything that is in the content recycle bin, has a path that starts with the prefix. + /// Use this instead of re-creating the string everywhere. + /// + public const string RecycleBinContentPathPrefix = "-1,-20,"; + /// /// The integer identifier for media's recycle bin. /// public const int RecycleBinMedia = -21; + /// + /// The string identifier for media's recycle bin. + /// + /// Use this instead of re-creating the string everywhere. + public const string RecycleBinMediaString = "-21"; + + /// + /// The string path prefix of the media's recycle bin. + /// + /// + /// Everything that is in the media recycle bin, has a path that starts with the prefix. + /// Use this instead of re-creating the string everywhere. + /// + public const string RecycleBinMediaPathPrefix = "-1,-21,"; + public const string UmbracoConnectionName = "umbracoDbDSN"; public const string UmbracoUpgradePlanName = "Umbraco.Core"; } diff --git a/src/Umbraco.Core/DateTimeExtensions.cs b/src/Umbraco.Core/DateTimeExtensions.cs index d82ec99c6a..b3babd2d07 100644 --- a/src/Umbraco.Core/DateTimeExtensions.cs +++ b/src/Umbraco.Core/DateTimeExtensions.cs @@ -21,9 +21,9 @@ namespace Umbraco.Core public static DateTime TruncateTo(this DateTime dt, DateTruncate truncateTo) { if (truncateTo == DateTruncate.Year) - return new DateTime(dt.Year, 0, 0); + return new DateTime(dt.Year, 1, 1); if (truncateTo == DateTruncate.Month) - return new DateTime(dt.Year, dt.Month, 0); + return new DateTime(dt.Year, dt.Month, 1); if (truncateTo == DateTruncate.Day) return new DateTime(dt.Year, dt.Month, dt.Day); if (truncateTo == DateTruncate.Hour) diff --git a/src/Umbraco.Core/DisposableObject.cs b/src/Umbraco.Core/DisposableObject.cs index 956804e347..ecdc149f6e 100644 --- a/src/Umbraco.Core/DisposableObject.cs +++ b/src/Umbraco.Core/DisposableObject.cs @@ -6,6 +6,9 @@ namespace Umbraco.Core /// Abstract implementation of IDisposable. /// /// + /// This is for objects that DO have unmanaged resources. Use + /// for objects that do NOT have unmanaged resources, and avoid creating a finalizer. + /// /// Can also be used as a pattern for when inheriting is not possible. /// /// See also: https://msdn.microsoft.com/en-us/library/b1yfkh5e%28v=vs.110%29.aspx diff --git a/src/Umbraco.Core/DisposableObjectSlim.cs b/src/Umbraco.Core/DisposableObjectSlim.cs new file mode 100644 index 0000000000..4992f8bc0f --- /dev/null +++ b/src/Umbraco.Core/DisposableObjectSlim.cs @@ -0,0 +1,53 @@ +using System; + +namespace Umbraco.Core +{ + /// + /// Abstract implementation of managed IDisposable. + /// + /// + /// This is for objects that do NOT have unmanaged resources. Use + /// for objects that DO have unmanaged resources and need to deal with them when disposing. + /// + /// Can also be used as a pattern for when inheriting is not possible. + /// + /// See also: https://msdn.microsoft.com/en-us/library/b1yfkh5e%28v=vs.110%29.aspx + /// See also: https://lostechies.com/chrispatterson/2012/11/29/idisposable-done-right/ + /// + /// Note: if an object's ctor throws, it will never be disposed, and so if that ctor + /// has allocated disposable objects, it should take care of disposing them. + /// + public abstract class DisposableObjectSlim : IDisposable + { + private readonly object _locko = new object(); + + // gets a value indicating whether this instance is disposed. + // for internal tests only (not thread safe) + public bool Disposed { get; private set; } + + // implements IDisposable + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + // can happen if the object construction failed + if (_locko == null) + return; + + lock (_locko) + { + if (Disposed) return; + Disposed = true; + } + + if (disposing) + DisposeResources(); + } + + protected virtual void DisposeResources() { } + } +} diff --git a/src/Umbraco.Core/DisposableTimer.cs b/src/Umbraco.Core/DisposableTimer.cs index 819e86f8e1..6ded588be6 100644 --- a/src/Umbraco.Core/DisposableTimer.cs +++ b/src/Umbraco.Core/DisposableTimer.cs @@ -7,7 +7,7 @@ namespace Umbraco.Core /// /// Starts the timer and invokes a callback upon disposal. Provides a simple way of timing an operation by wrapping it in a using (C#) statement. /// - public class DisposableTimer : DisposableObject + public class DisposableTimer : DisposableObjectSlim { private readonly ILogger _logger; private readonly LogType? _logType; diff --git a/src/Umbraco.Core/HashGenerator.cs b/src/Umbraco.Core/HashGenerator.cs index 0f03e22a9b..f8e45c362d 100644 --- a/src/Umbraco.Core/HashGenerator.cs +++ b/src/Umbraco.Core/HashGenerator.cs @@ -14,7 +14,7 @@ namespace Umbraco.Core /// This will use the crypto libs to generate the hash and will try to ensure that /// strings, etc... are not re-allocated so it's not consuming much memory. /// - internal class HashGenerator : DisposableObject + internal class HashGenerator : DisposableObjectSlim { public HashGenerator() { diff --git a/src/Umbraco.Core/HttpContextExtensions.cs b/src/Umbraco.Core/HttpContextExtensions.cs index df134ad7ca..e370b055a4 100644 --- a/src/Umbraco.Core/HttpContextExtensions.cs +++ b/src/Umbraco.Core/HttpContextExtensions.cs @@ -40,13 +40,13 @@ namespace Umbraco.Core var ipAddress = httpContext.Request.ServerVariables["HTTP_X_FORWARDED_FOR"]; if (string.IsNullOrEmpty(ipAddress)) - return httpContext.Request.ServerVariables["REMOTE_ADDR"]; + return httpContext.Request.UserHostAddress; var addresses = ipAddress.Split(','); if (addresses.Length != 0) return addresses[0]; - return httpContext.Request.ServerVariables["REMOTE_ADDR"]; + return httpContext.Request.UserHostAddress; } catch (System.Exception ex) { diff --git a/src/Umbraco.Core/IO/IOHelper.cs b/src/Umbraco.Core/IO/IOHelper.cs index 94d2ccfe7c..5a2928f795 100644 --- a/src/Umbraco.Core/IO/IOHelper.cs +++ b/src/Umbraco.Core/IO/IOHelper.cs @@ -13,11 +13,22 @@ namespace Umbraco.Core.IO { public static class IOHelper { + /// + /// Gets or sets a value forcing Umbraco to consider it is non-hosted. + /// + /// This should always be false, unless unit testing. + public static bool ForceNotHosted { get; set; } + private static string _rootDir = ""; // static compiled regex for faster performance //private static readonly Regex ResolveUrlPattern = new Regex("(=[\"\']?)(\\W?\\~(?:.(?![\"\']?\\s+(?:\\S+)=|[>\"\']))+.)[\"\']?", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); + /// + /// Gets a value indicating whether Umbraco is hosted. + /// + public static bool IsHosted => !ForceNotHosted && (HttpContext.Current != null || HostingEnvironment.IsHosted); + public static char DirSepChar => Path.DirectorySeparatorChar; internal static void UnZip(string zipFilePath, string unPackDirectory, bool deleteZipFile) @@ -80,6 +91,7 @@ namespace Umbraco.Core.IO public static string MapPath(string path, bool useHttpContext) { if (path == null) throw new ArgumentNullException("path"); + useHttpContext = useHttpContext && IsHosted; // Check if the path is already mapped if ((path.Length >= 2 && path[1] == Path.VolumeSeparatorChar) diff --git a/src/Umbraco.Core/IO/PhysicalFileSystem.cs b/src/Umbraco.Core/IO/PhysicalFileSystem.cs index 3b9cac42a7..9a2c6eb1de 100644 --- a/src/Umbraco.Core/IO/PhysicalFileSystem.cs +++ b/src/Umbraco.Core/IO/PhysicalFileSystem.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using Umbraco.Core.Composing; using Umbraco.Core.Exceptions; +using System.Threading; using Umbraco.Core.Logging; namespace Umbraco.Core.IO @@ -104,7 +105,7 @@ namespace Umbraco.Core.IO try { - Directory.Delete(fullPath, recursive); + WithRetry(() => Directory.Delete(fullPath, recursive)); } catch (DirectoryNotFoundException ex) { @@ -221,7 +222,7 @@ namespace Umbraco.Core.IO try { - File.Delete(fullPath); + WithRetry(() => File.Delete(fullPath)); } catch (FileNotFoundException ex) { @@ -371,7 +372,7 @@ namespace Umbraco.Core.IO { if (overrideIfExists == false) throw new InvalidOperationException($"A file at path '{path}' already exists"); - File.Delete(fullPath); + WithRetry(() => File.Delete(fullPath)); } var directory = Path.GetDirectoryName(fullPath); @@ -379,9 +380,9 @@ namespace Umbraco.Core.IO Directory.CreateDirectory(directory); // ensure it exists if (copy) - File.Copy(physicalPath, fullPath); + WithRetry(() => File.Copy(physicalPath, fullPath)); else - File.Move(physicalPath, fullPath); + WithRetry(() => File.Move(physicalPath, fullPath)); } #region Helper Methods @@ -410,6 +411,35 @@ namespace Umbraco.Core.IO return path; } + protected void WithRetry(Action action) + { + // 10 times 100ms is 1s + const int count = 10; + const int pausems = 100; + + for (var i = 0;; i++) + { + try + { + action(); + break; // done + } + catch (IOException e) + { + // if it's not *exactly* IOException then it could be + // some inherited exception such as FileNotFoundException, + // and then we don't want to retry + if (e.GetType() != typeof(IOException)) throw; + + // if we have tried enough, throw, else swallow + // the exception and retry after a pause + if (i == count) throw; + } + + Thread.Sleep(pausems); + } + } + #endregion } } diff --git a/src/Umbraco.Core/IO/SystemFiles.cs b/src/Umbraco.Core/IO/SystemFiles.cs index fa22a9a447..20b7bf6a3e 100644 --- a/src/Umbraco.Core/IO/SystemFiles.cs +++ b/src/Umbraco.Core/IO/SystemFiles.cs @@ -22,19 +22,19 @@ namespace Umbraco.Core.IO { get { - switch (GlobalSettings.ContentCacheXmlStorageLocation) + switch (GlobalSettings.LocalTempStorageLocation) { - case ContentXmlStorage.AspNetTemp: + case LocalTempStorage.AspNetTemp: return Path.Combine(HttpRuntime.CodegenDir, @"UmbracoData\umbraco.config"); - case ContentXmlStorage.EnvironmentTemp: + case LocalTempStorage.EnvironmentTemp: var appDomainHash = HttpRuntime.AppDomainAppId.ToSHA1(); - var cachePath = Path.Combine(Environment.ExpandEnvironmentVariables("%temp%"), "UmbracoXml", + var cachePath = Path.Combine(Environment.ExpandEnvironmentVariables("%temp%"), "UmbracoData", //include the appdomain hash is just a safety check, for example if a website is moved from worker A to worker B and then back // to worker A again, in theory the %temp% folder should already be empty but we really want to make sure that its not // utilizing an old path appDomainHash); return Path.Combine(cachePath, "umbraco.config"); - case ContentXmlStorage.Default: + case LocalTempStorage.Default: return IOHelper.ReturnPath("umbracoContentXML", "~/App_Data/umbraco.config"); default: throw new ArgumentOutOfRangeException(); diff --git a/src/Umbraco.Core/Manifest/ManifestWatcher.cs b/src/Umbraco.Core/Manifest/ManifestWatcher.cs index 014721eb88..3bc70e2d78 100644 --- a/src/Umbraco.Core/Manifest/ManifestWatcher.cs +++ b/src/Umbraco.Core/Manifest/ManifestWatcher.cs @@ -7,7 +7,7 @@ using Umbraco.Core.Logging; namespace Umbraco.Core.Manifest { - internal class ManifestWatcher : DisposableObject + internal class ManifestWatcher : DisposableObjectSlim { private static readonly object Locker = new object(); private static volatile bool _isRestarting; diff --git a/src/Umbraco.Core/Scoping/ScopeContext.cs b/src/Umbraco.Core/Scoping/ScopeContext.cs index 97b655ed63..dd26fda85e 100644 --- a/src/Umbraco.Core/Scoping/ScopeContext.cs +++ b/src/Umbraco.Core/Scoping/ScopeContext.cs @@ -79,16 +79,30 @@ namespace Umbraco.Core.Scoping var enlistedObjects = _enlisted ?? (_enlisted = new Dictionary()); - if (enlistedObjects.TryGetValue(key, out IEnlistedObject enlisted)) + if (enlistedObjects.TryGetValue(key, out var enlisted)) { - var enlistedAs = enlisted as EnlistedObject; - if (enlistedAs == null) throw new InvalidOperationException("An item with the key already exists, but with a different type."); - if (enlistedAs.Priority != priority) throw new InvalidOperationException("An item with the key already exits, but with a different priority."); + if (!(enlisted is EnlistedObject enlistedAs)) + throw new InvalidOperationException("An item with the key already exists, but with a different type."); + if (enlistedAs.Priority != priority) + throw new InvalidOperationException("An item with the key already exits, but with a different priority."); return enlistedAs.Item; } - var enlistedOfT = new EnlistedObject(creator == null ? default(T) : creator(), action, priority); + var enlistedOfT = new EnlistedObject(creator == null ? default : creator(), action, priority); Enlisted[key] = enlistedOfT; return enlistedOfT.Item; } + + public T GetEnlisted(string key) + { + var enlistedObjects = _enlisted; + if (enlistedObjects == null) return default; + + if (enlistedObjects.TryGetValue(key, out var enlisted) == false) + return default; + + if (!(enlisted is EnlistedObject enlistedAs)) + throw new InvalidOperationException("An item with the key exists, but with a different type."); + return enlistedAs.Item; + } } } diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs index 5ff4052a45..42a2d3b5d4 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs @@ -680,19 +680,14 @@ function validateSafeAlias(input, value, immediate, callback) {{ /// The filtered string. public virtual string ReplaceMany(string text, IDictionary replacements) { - // be safe if (text == null) throw new ArgumentNullException(nameof(text)); if (replacements == null) throw new ArgumentNullException(nameof(replacements)); - // Have done various tests, implementing my own "super fast" state machine to handle - // replacement of many items, or via regexes, but on short strings and not too - // many replacements (which prob. is going to be our case) nothing can beat this... - // (at least with safe and checked code -- we don't want unsafe/unchecked here) - // Note that it will do chained-replacements ie replaced items can be replaced - // in turn by another replacement (ie the order of replacements is important) + foreach (KeyValuePair item in replacements) + text = text.Replace(item.Key, item.Value); - return replacements.Aggregate(text, (current, kvp) => current.Replace(kvp.Key, kvp.Value)); + return text; } /// @@ -704,13 +699,14 @@ function validateSafeAlias(input, value, immediate, callback) {{ /// The filtered string. public virtual string ReplaceMany(string text, char[] chars, char replacement) { - // be safe if (text == null) throw new ArgumentNullException(nameof(text)); if (chars == null) throw new ArgumentNullException(nameof(chars)); - // see note above - return chars.Aggregate(text, (current, c) => current.Replace(c, replacement)); + for (int i = 0; i < chars.Length; i++) + text = text.Replace(chars[i], replacement); + + return text; } #endregion diff --git a/src/Umbraco.Core/UdiEntityType.cs b/src/Umbraco.Core/UdiEntityType.cs index 047bb3198f..34bd26b537 100644 --- a/src/Umbraco.Core/UdiEntityType.cs +++ b/src/Umbraco.Core/UdiEntityType.cs @@ -26,7 +26,7 @@ namespace Umbraco.Core { AnyGuid, UdiType.GuidUdi }, { Document, UdiType.GuidUdi }, - { DocumentBluePrint, UdiType.GuidUdi }, + { DocumentBlueprint, UdiType.GuidUdi }, { Media, UdiType.GuidUdi }, { Member, UdiType.GuidUdi }, { DictionaryItem, UdiType.GuidUdi }, @@ -67,7 +67,7 @@ namespace Umbraco.Core public const string Document = "document"; - public const string DocumentBluePrint = "document-blueprint"; + public const string DocumentBlueprint = "document-blueprint"; public const string Media = "media"; public const string Member = "member"; @@ -79,6 +79,7 @@ namespace Umbraco.Core public const string DocumentType = "document-type"; public const string DocumentTypeContainer = "document-type-container"; + //TODO: What is this? This alias is only used for the blue print tree to render the blueprint's document type, it's not a real udi type public const string DocumentTypeBluePrints = "document-type-blueprints"; public const string MediaType = "media-type"; public const string MediaTypeContainer = "media-type-container"; @@ -115,6 +116,8 @@ namespace Umbraco.Core { case UmbracoObjectTypes.Document: return Document; + case UmbracoObjectTypes.DocumentBlueprint: + return DocumentBlueprint; case UmbracoObjectTypes.Media: return Media; case UmbracoObjectTypes.Member: @@ -159,6 +162,8 @@ namespace Umbraco.Core { case Document: return UmbracoObjectTypes.Document; + case DocumentBlueprint: + return UmbracoObjectTypes.DocumentBlueprint; case Media: return UmbracoObjectTypes.Media; case Member: diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 00f1a85d79..434f25638e 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -182,7 +182,6 @@ - @@ -230,6 +229,7 @@ + @@ -237,6 +237,7 @@ + @@ -251,6 +252,7 @@ + @@ -272,6 +274,7 @@ + @@ -290,6 +293,7 @@ + @@ -312,6 +316,7 @@ + diff --git a/src/Umbraco.Core/UriExtensions.cs b/src/Umbraco.Core/UriExtensions.cs index 6c37a43623..d58814590d 100644 --- a/src/Umbraco.Core/UriExtensions.cs +++ b/src/Umbraco.Core/UriExtensions.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using Umbraco.Core.Configuration; using Umbraco.Core.IO; +using Umbraco.Core.Logging; namespace Umbraco.Core { @@ -142,10 +143,18 @@ namespace Umbraco.Core /// internal static bool IsClientSideRequest(this Uri url) { - var ext = Path.GetExtension(url.LocalPath); - if (ext.IsNullOrWhiteSpace()) return false; - var toInclude = new[] { ".aspx", ".ashx", ".asmx", ".axd", ".svc" }; - return toInclude.Any(ext.InvariantEquals) == false; + try + { + var ext = Path.GetExtension(url.LocalPath); + if (ext.IsNullOrWhiteSpace()) return false; + var toInclude = new[] {".aspx", ".ashx", ".asmx", ".axd", ".svc"}; + return toInclude.Any(ext.InvariantEquals) == false; + } + catch (ArgumentException ex) + { + LogHelper.Error(typeof(UriExtensions), "Failed to determine if request was client side", ex); + return false; + } } /// From 41948607d04e379047017ac4e6aea503b819c8cf Mon Sep 17 00:00:00 2001 From: Stephan Date: Wed, 21 Mar 2018 09:06:32 +0100 Subject: [PATCH 04/15] Port v7@2aa0dfb2c5 - WIP --- .../Collections/CompositeTypeTypeKey.cs | 9 +- src/Umbraco.Core/Events/EventMessages.cs | 2 +- .../Events/ExportedMemberEventArgs.cs | 18 + .../Events/ImportPackageEventArgs.cs | 14 +- .../Events/QueuingEventDispatcherBase.cs | 313 +++++----- src/Umbraco.Core/Events/RolesEventArgs.cs | 16 + src/Umbraco.Core/Events/UserGroupWithUsers.cs | 19 + .../Logging/RollingFileCleanupAppender.cs | 95 +++ .../Models/Entities/BeingDirtyBase.cs | 9 + .../Models/Entities/IRememberBeingDirty.cs | 9 +- .../Models/Identity/BackOfficeIdentityUser.cs | 8 + .../Models/Identity/IdentityProfile.cs | 14 - .../Membership/EntityPermissionCollection.cs | 25 +- src/Umbraco.Core/Models/Membership/IUser.cs | 9 +- .../Models/Membership/IUserGroup.cs | 2 +- .../Models/Membership/MemberExportModel.cs | 19 + .../Models/Membership/MemberExportProperty.cs | 14 + src/Umbraco.Core/Models/Membership/User.cs | 13 + .../Models/Membership/UserGroup.cs | 2 +- src/Umbraco.Core/ObjectExtensions.cs | 588 ++++++++++-------- .../Persistence/Constants-DatabaseSchema.cs | 4 + .../Persistence/Dtos/AuditEntryDto.cs | 59 ++ .../Persistence/Dtos/CacheInstructionDto.cs | 5 + .../Persistence/Dtos/ConsentDto.cs | 42 ++ .../Persistence/Dtos/MemberTypeDto.cs | 4 + .../Persistence/Dtos/PropertyTypeDto.cs | 1 + .../Dtos/PropertyTypeReadOnlyDto.cs | 3 + src/Umbraco.Core/Persistence/Dtos/UserDto.cs | 8 + .../Persistence/Dtos/UserGroupDto.cs | 2 +- .../Persistence/Dtos/UserLoginDto.cs | 52 ++ src/Umbraco.Core/Properties/AssemblyInfo.cs | 3 + src/Umbraco.Core/RuntimeState.cs | 15 +- .../Security/AuthenticationExtensions.cs | 34 +- .../BackOfficeClaimsIdentityFactory.cs | 28 +- .../BackOfficeCookieAuthenticationProvider.cs | 96 ++- .../Security/BackOfficeSignInManager.cs | 66 ++ .../Security/BackOfficeUserManager.cs | 70 ++- .../Security/BackOfficeUserStore.cs | 30 +- .../Security/IUserSessionStore.cs | 17 + .../Security/MembershipProviderBase.cs | 8 +- src/Umbraco.Core/Security/OwinExtensions.cs | 32 + .../Security/SessionIdValidator.cs | 118 ++++ .../Security/UmbracoBackOfficeIdentity.cs | 24 +- src/Umbraco.Core/Security/UserData.cs | 10 +- src/Umbraco.Core/Sync/ApplicationUrlHelper.cs | 2 +- .../Sync/DatabaseServerMessenger.cs | 67 +- src/Umbraco.Core/Sync/RefreshInstruction.cs | 39 +- src/Umbraco.Core/Umbraco.Core.csproj | 14 + 48 files changed, 1537 insertions(+), 514 deletions(-) create mode 100644 src/Umbraco.Core/Events/ExportedMemberEventArgs.cs create mode 100644 src/Umbraco.Core/Events/RolesEventArgs.cs create mode 100644 src/Umbraco.Core/Events/UserGroupWithUsers.cs create mode 100644 src/Umbraco.Core/Logging/RollingFileCleanupAppender.cs create mode 100644 src/Umbraco.Core/Models/Membership/MemberExportModel.cs create mode 100644 src/Umbraco.Core/Models/Membership/MemberExportProperty.cs create mode 100644 src/Umbraco.Core/Persistence/Dtos/AuditEntryDto.cs create mode 100644 src/Umbraco.Core/Persistence/Dtos/ConsentDto.cs create mode 100644 src/Umbraco.Core/Persistence/Dtos/UserLoginDto.cs create mode 100644 src/Umbraco.Core/Security/IUserSessionStore.cs create mode 100644 src/Umbraco.Core/Security/SessionIdValidator.cs diff --git a/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs b/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs index 1a4e7ae1a9..cc555afe55 100644 --- a/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs +++ b/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs @@ -10,7 +10,8 @@ namespace Umbraco.Core.Collections /// /// Initializes a new instance of the struct. /// - public CompositeTypeTypeKey(Type type1, Type type2) : this() + public CompositeTypeTypeKey(Type type1, Type type2) + : this() { Type1 = type1; Type2 = type2; @@ -19,12 +20,12 @@ namespace Umbraco.Core.Collections /// /// Gets the first type. /// - public Type Type1 { get; private set; } + public Type Type1 { get; } /// /// Gets the second type. /// - public Type Type2 { get; private set; } + public Type Type2 { get; } /// public bool Equals(CompositeTypeTypeKey other) @@ -35,7 +36,7 @@ namespace Umbraco.Core.Collections /// public override bool Equals(object obj) { - var other = obj is CompositeTypeTypeKey ? (CompositeTypeTypeKey)obj : default(CompositeTypeTypeKey); + var other = obj is CompositeTypeTypeKey key ? key : default; return Type1 == other.Type1 && Type2 == other.Type2; } diff --git a/src/Umbraco.Core/Events/EventMessages.cs b/src/Umbraco.Core/Events/EventMessages.cs index aac0d07e85..2df1911249 100644 --- a/src/Umbraco.Core/Events/EventMessages.cs +++ b/src/Umbraco.Core/Events/EventMessages.cs @@ -5,7 +5,7 @@ namespace Umbraco.Core.Events /// /// Event messages collection /// - public sealed class EventMessages : DisposableObject + public sealed class EventMessages : DisposableObjectSlim { private readonly List _msgs = new List(); diff --git a/src/Umbraco.Core/Events/ExportedMemberEventArgs.cs b/src/Umbraco.Core/Events/ExportedMemberEventArgs.cs new file mode 100644 index 0000000000..9c91f3e5bd --- /dev/null +++ b/src/Umbraco.Core/Events/ExportedMemberEventArgs.cs @@ -0,0 +1,18 @@ +using System; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Membership; + +namespace Umbraco.Core.Events +{ + internal class ExportedMemberEventArgs : EventArgs + { + public IMember Member { get; } + public MemberExportModel Exported { get; } + + public ExportedMemberEventArgs(IMember member, MemberExportModel exported) + { + Member = member; + Exported = exported; + } + } +} diff --git a/src/Umbraco.Core/Events/ImportPackageEventArgs.cs b/src/Umbraco.Core/Events/ImportPackageEventArgs.cs index 6ad7b52806..7536b43e93 100644 --- a/src/Umbraco.Core/Events/ImportPackageEventArgs.cs +++ b/src/Umbraco.Core/Events/ImportPackageEventArgs.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using Umbraco.Core.Models.Packaging; namespace Umbraco.Core.Events @@ -8,17 +9,26 @@ namespace Umbraco.Core.Events { private readonly MetaData _packageMetaData; + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Use the overload specifying packageMetaData instead")] public ImportPackageEventArgs(TEntity eventObject, bool canCancel) : base(new[] { eventObject }, canCancel) { } - public ImportPackageEventArgs(TEntity eventObject, MetaData packageMetaData) - : base(new[] { eventObject }) + public ImportPackageEventArgs(TEntity eventObject, MetaData packageMetaData, bool canCancel) + : base(new[] { eventObject }, canCancel) { + if (packageMetaData == null) throw new ArgumentNullException("packageMetaData"); _packageMetaData = packageMetaData; } + public ImportPackageEventArgs(TEntity eventObject, MetaData packageMetaData) + : this(eventObject, packageMetaData, true) + { + + } + public MetaData PackageMetaData { get { return _packageMetaData; } diff --git a/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs b/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs index 2cc7046078..0283ac372e 100644 --- a/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs +++ b/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs @@ -78,255 +78,256 @@ namespace Umbraco.Core.Events if (_events == null) return Enumerable.Empty(); + IReadOnlyList events; switch (filter) { case EventDefinitionFilter.All: - return FilterSupersededAndUpdateToLatestEntity(_events); + events = _events; + break; case EventDefinitionFilter.FirstIn: var l1 = new OrderedHashSet(); foreach (var e in _events) l1.Add(e); - return FilterSupersededAndUpdateToLatestEntity(l1); + events = l1; + break; case EventDefinitionFilter.LastIn: var l2 = new OrderedHashSet(keepOldest: false); foreach (var e in _events) l2.Add(e); - return FilterSupersededAndUpdateToLatestEntity(l2); + events = l2; + break; default: - throw new ArgumentOutOfRangeException(nameof(filter), filter, null); + throw new ArgumentOutOfRangeException("filter", filter, null); } + + return FilterSupersededAndUpdateToLatestEntity(events); } - private class EventDefinitionTypeData + private class EventDefinitionInfos { public IEventDefinition EventDefinition { get; set; } - public Type EventArgType { get; set; } - public SupersedeEventAttribute[] SupersedeAttributes { get; set; } + public Type[] SupersedeTypes { get; set; } } - /// - /// This will iterate over the events (latest first) and filter out any events or entities in event args that are included - /// in more recent events that Supersede previous ones. For example, If an Entity has been Saved and then Deleted, we don't want - /// to raise the Saved event (well actually we just don't want to include it in the args for that saved event) - /// - /// - /// - private static IEnumerable FilterSupersededAndUpdateToLatestEntity(IReadOnlyList events) + // fixme + // this is way too convoluted, the superceede attribute is used only on DeleteEventargs to specify + // that it superceeds save, publish, move and copy - BUT - publish event args is also used for + // unpublishing and should NOT be superceeded - so really it should not be managed at event args + // level but at event level + // + // what we want is: + // if an entity is deleted, then all Saved, Moved, Copied, Published events prior to this should + // not trigger for the entity - and even though, does it make any sense? making a copy of an entity + // should ... trigger? + // + // not going to refactor it all - we probably want to *always* trigger event but tell people that + // due to scopes, they should not expected eg a saved entity to still be around - however, now, + // going to write a ugly condition to deal with U4-10764 + + // iterates over the events (latest first) and filter out any events or entities in event args that are included + // in more recent events that Supersede previous ones. For example, If an Entity has been Saved and then Deleted, we don't want + // to raise the Saved event (well actually we just don't want to include it in the args for that saved event) + internal static IEnumerable FilterSupersededAndUpdateToLatestEntity(IReadOnlyList events) { - //used to keep the 'latest' entity and associated event definition data - var allEntities = new List>(); - - //tracks all CancellableObjectEventArgs instances in the events which is the only type of args we can work with - var cancelableArgs = new List(); + // keeps the 'latest' entity and associated event data + var entities = new List>(); + // collects the event definitions + // collects the arguments in result, that require their entities to be updated var result = new List(); + var resultArgs = new List(); - //This will eagerly load all of the event arg types and their attributes so we don't have to continuously look this data up - var allArgTypesWithAttributes = events.Select(x => x.Args.GetType()) + // eagerly fetch superceeded arg types for each arg type + var argTypeSuperceeding = events.Select(x => x.Args.GetType()) .Distinct() - .ToDictionary(x => x, x => x.GetCustomAttributes(false).ToArray()); + .ToDictionary(x => x, x => x.GetCustomAttributes(false).Select(y => y.SupersededEventArgsType).ToArray()); - //Iterate all events and collect the actual entities in them and relates them to their corresponding EventDefinitionTypeData - //we'll process the list in reverse because events are added in the order they are raised and we want to filter out - //any entities from event args that are not longer relevant - //(i.e. if an item is Deleted after it's Saved, we won't include the item in the Saved args) + // iterate over all events and filter + // + // process the list in reverse, because events are added in the order they are raised and we want to keep + // the latest (most recent) entities and filter out what is not relevant anymore (too old), eg if an entity + // is Deleted after being Saved, we want to filter out the Saved event for (var index = events.Count - 1; index >= 0; index--) { - var eventDefinition = events[index]; + var def = events[index]; - var argType = eventDefinition.Args.GetType(); - var attributes = allArgTypesWithAttributes[eventDefinition.Args.GetType()]; - - var meta = new EventDefinitionTypeData + var infos = new EventDefinitionInfos { - EventDefinition = eventDefinition, - EventArgType = argType, - SupersedeAttributes = attributes + EventDefinition = def, + SupersedeTypes = argTypeSuperceeding[def.Args.GetType()] }; - var args = eventDefinition.Args as CancellableObjectEventArgs; - if (args != null) + var args = def.Args as CancellableObjectEventArgs; + if (args == null) { - var list = TypeHelper.CreateGenericEnumerableFromObject(args.EventObject); - - if (list == null) + // not a cancellable event arg, include event definition in result + result.Add(def); + } + else + { + // event object can either be a single object or an enumerable of objects + // try to get as an enumerable, get null if it's not + var eventObjects = TypeHelper.CreateGenericEnumerableFromObject(args.EventObject); + if (eventObjects == null) { - //extract the event object - var obj = args.EventObject as IEntity; - if (obj != null) + // single object, cast as an IEntity + // if cannot cast, cannot filter, nothing - just include event definition in result + var eventEntity = args.EventObject as IEntity; + if (eventEntity == null) { - //Now check if this entity already exists in other event args that supersede this current event arg type - if (IsFiltered(obj, meta, allEntities) == false) - { - //if it's not filtered we can adde these args to the response - cancelableArgs.Add(args); - result.Add(eventDefinition); - //track the entity - allEntities.Add(Tuple.Create(obj, meta)); - } + result.Add(def); + continue; } - else + + // look for this entity in superceding event args + // found = must be removed (ie not added), else track + if (IsSuperceeded(eventEntity, infos, entities) == false) { - //Can't retrieve the entity so cant' filter or inspect, just add to the output - result.Add(eventDefinition); + // track + entities.Add(Tuple.Create(eventEntity, infos)); + + // track result arguments + // include event definition in result + resultArgs.Add(args); + result.Add(def); } } else { + // enumerable of objects var toRemove = new List(); - foreach (var entity in list) + foreach (var eventObject in eventObjects) { - //extract the event object - var obj = entity as IEntity; - if (obj != null) - { - //Now check if this entity already exists in other event args that supersede this current event arg type - if (IsFiltered(obj, meta, allEntities)) - { - //track it to be removed - toRemove.Add(obj); - } - else - { - //track the entity, it's not filtered - allEntities.Add(Tuple.Create(obj, meta)); - } - } + // extract the event object, cast as an IEntity + // if cannot cast, cannot filter, nothing to do - just leave it in the list & continue + var eventEntity = eventObject as IEntity; + if (eventEntity == null) + continue; + + // look for this entity in superceding event args + // found = must be removed, else track + if (IsSuperceeded(eventEntity, infos, entities)) + toRemove.Add(eventEntity); else - { - //we don't need to do anything here, we can't cast to IEntity so we cannot filter, so it will just remain in the list - } + entities.Add(Tuple.Create(eventEntity, infos)); } - //remove anything that has been filtered + // remove superceded entities foreach (var entity in toRemove) - { - list.Remove(entity); - } + eventObjects.Remove(entity); - //track the event and include in the response if there's still entities remaining in the list - if (list.Count > 0) + // if there are still entities in the list, keep the event definition + if (eventObjects.Count > 0) { if (toRemove.Count > 0) { - //re-assign if the items have changed - args.EventObject = list; + // re-assign if changed + args.EventObject = eventObjects; } - cancelableArgs.Add(args); - result.Add(eventDefinition); + + // track result arguments + // include event definition in result + resultArgs.Add(args); + result.Add(def); } } } - else - { - //it's not a cancelable event arg so we just include it in the result - result.Add(eventDefinition); - } } - //Now we'll deal with ensuring that only the latest(non stale) entities are used throughout all event args - UpdateToLatestEntities(allEntities, cancelableArgs); + // go over all args in result, and update them with the latest instanceof each entity + UpdateToLatestEntities(entities, resultArgs); - //we need to reverse the result since we've been adding by latest added events first! + // reverse, since we processed the list in reverse result.Reverse(); return result; } - private static void UpdateToLatestEntities(IEnumerable> allEntities, IEnumerable cancelableArgs) + // edits event args to use the latest instance of each entity + private static void UpdateToLatestEntities(IEnumerable> entities, IEnumerable args) { - //Now we'll deal with ensuring that only the latest(non stale) entities are used throughout all event args - + // get the latest entities + // ordered hash set + keepOldest will keep the latest inserted entity (in case of duplicates) var latestEntities = new OrderedHashSet(keepOldest: true); - foreach (var entity in allEntities.OrderByDescending(entity => entity.Item1.UpdateDate)) - { + foreach (var entity in entities.OrderByDescending(entity => entity.Item1.UpdateDate)) latestEntities.Add(entity.Item1); - } - foreach (var args in cancelableArgs) + foreach (var arg in args) { - var list = TypeHelper.CreateGenericEnumerableFromObject(args.EventObject); - if (list == null) + // event object can either be a single object or an enumerable of objects + // try to get as an enumerable, get null if it's not + var eventObjects = TypeHelper.CreateGenericEnumerableFromObject(arg.EventObject); + if (eventObjects == null) { - //try to find the args entity in the latest entity - based on the equality operators, this will - //match by Id since that is the default equality checker for IEntity. If one is found, than it is - //the most recent entity instance so update the args with that instance so we don't emit a stale instance. - var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, args.EventObject)); + // single object + // look for a more recent entity for that object, and replace if any + // works by "equalling" entities ie the more recent one "equals" this one (though different object) + var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, arg.EventObject)); if (foundEntity != null) - { - args.EventObject = foundEntity; - } + arg.EventObject = foundEntity; } else { + // enumerable of objects + // same as above but for each object var updated = false; - - for (int i = 0; i < list.Count; i++) + for (var i = 0; i < eventObjects.Count; i++) { - //try to find the args entity in the latest entity - based on the equality operators, this will - //match by Id since that is the default equality checker for IEntity. If one is found, than it is - //the most recent entity instance so update the args with that instance so we don't emit a stale instance. - var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, list[i])); - if (foundEntity != null) - { - list[i] = foundEntity; - updated = true; - } + var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, eventObjects[i])); + if (foundEntity == null) continue; + eventObjects[i] = foundEntity; + updated = true; } if (updated) - { - args.EventObject = list; - } + arg.EventObject = eventObjects; } } } - /// - /// This will check against all of the processed entity/events (allEntities) to see if this entity already exists in - /// event args that supersede the event args being passed in and if so returns true. - /// - /// - /// - /// - /// - private static bool IsFiltered( - IEntity entity, - EventDefinitionTypeData eventDef, - List> allEntities) + // determines if a given entity, appearing in a given event definition, should be filtered out, + // considering the entities that have already been visited - an entity is filtered out if it + // appears in another even definition, which superceedes this event definition. + private static bool IsSuperceeded(IEntity entity, EventDefinitionInfos infos, List> entities) { - var argType = eventDef.EventDefinition.Args.GetType(); + //var argType = meta.EventArgsType; + var argType = infos.EventDefinition.Args.GetType(); - //check if the entity is found in any processed event data that could possible supersede this one - var foundByEntity = allEntities - .Where(x => x.Item2.SupersedeAttributes.Length > 0 - //if it's the same arg type than it cannot supersede - && x.Item2.EventArgType != argType - && Equals(x.Item1, entity)) + // look for other instances of the same entity, coming from an event args that supercedes other event args, + // ie is marked with the attribute, and is not this event args (cannot supersede itself) + var superceeding = entities + .Where(x => x.Item2.SupersedeTypes.Length > 0 // has the attribute + && x.Item2.EventDefinition.Args.GetType() != argType // is not the same + && Equals(x.Item1, entity)) // same entity .ToArray(); - //no args have been processed with this entity so it should not be filtered - if (foundByEntity.Length == 0) + // first time we see this entity = not filtered + if (superceeding.Length == 0) return false; + // fixme see notes above + // delete event args does NOT superceedes 'unpublished' event + if (argType.IsGenericType && argType.GetGenericTypeDefinition() == typeof(PublishEventArgs<>) && infos.EventDefinition.EventName == "UnPublished") + return false; + + // found occurences, need to determine if this event args is superceded if (argType.IsGenericType) { - var supercededBy = foundByEntity - .FirstOrDefault(x => - x.Item2.SupersedeAttributes.Any(y => - //if the attribute type is a generic type def then compare with the generic type def of the event arg - (y.SupersededEventArgsType.IsGenericTypeDefinition && y.SupersededEventArgsType == argType.GetGenericTypeDefinition()) - //if the attribute type is not a generic type def then compare with the normal type of the event arg - || (y.SupersededEventArgsType.IsGenericTypeDefinition == false && y.SupersededEventArgsType == argType))); + // generic, must compare type arguments + var supercededBy = superceeding.FirstOrDefault(x => + x.Item2.SupersedeTypes.Any(y => + // superceeding a generic type which has the same generic type definition + // fixme no matter the generic type parameters? could be different? + y.IsGenericTypeDefinition && y == argType.GetGenericTypeDefinition() + // or superceeding a non-generic type which is ... fixme how is this ever possible? argType *is* generic? + || y.IsGenericTypeDefinition == false && y == argType)); return supercededBy != null; } else { - var supercededBy = foundByEntity - .FirstOrDefault(x => - x.Item2.SupersedeAttributes.Any(y => - //since the event arg type is not a generic type, then we just compare type 1:1 - y.SupersededEventArgsType == argType)); + // non-generic, can compare types 1:1 + var supercededBy = superceeding.FirstOrDefault(x => + x.Item2.SupersedeTypes.Any(y => y == argType)); return supercededBy != null; } } diff --git a/src/Umbraco.Core/Events/RolesEventArgs.cs b/src/Umbraco.Core/Events/RolesEventArgs.cs new file mode 100644 index 0000000000..3104412f99 --- /dev/null +++ b/src/Umbraco.Core/Events/RolesEventArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace Umbraco.Core.Events +{ + public class RolesEventArgs : EventArgs + { + public RolesEventArgs(int[] memberIds, string[] roles) + { + MemberIds = memberIds; + Roles = roles; + } + + public int[] MemberIds { get; set; } + public string[] Roles { get; set; } + } +} diff --git a/src/Umbraco.Core/Events/UserGroupWithUsers.cs b/src/Umbraco.Core/Events/UserGroupWithUsers.cs new file mode 100644 index 0000000000..b69650d33f --- /dev/null +++ b/src/Umbraco.Core/Events/UserGroupWithUsers.cs @@ -0,0 +1,19 @@ +using System; +using Umbraco.Core.Models.Membership; + +namespace Umbraco.Core.Events +{ + internal class UserGroupWithUsers + { + public UserGroupWithUsers(IUserGroup userGroup, IUser[] addedUsers, IUser[] removedUsers) + { + UserGroup = userGroup; + AddedUsers = addedUsers; + RemovedUsers = removedUsers; + } + + public IUserGroup UserGroup { get; } + public IUser[] AddedUsers { get; } + public IUser[] RemovedUsers { get; } + } +} diff --git a/src/Umbraco.Core/Logging/RollingFileCleanupAppender.cs b/src/Umbraco.Core/Logging/RollingFileCleanupAppender.cs new file mode 100644 index 0000000000..6be2552296 --- /dev/null +++ b/src/Umbraco.Core/Logging/RollingFileCleanupAppender.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using log4net.Appender; +using log4net.Util; + +namespace Umbraco.Core.Logging +{ + /// + /// This class will do the exact same thing as the RollingFileAppender that comes from log4net + /// With the extension, that it is able to do automatic cleanup of the logfiles in the directory where logging happens + /// + /// By specifying the properties MaxLogFileDays and BaseFilePattern, the files will automaticly get deleted when + /// the logger is configured(typically when the app starts). To utilize this appender swap out the type of the rollingFile appender + /// that ships with Umbraco, to be Umbraco.Core.Logging.RollingFileCleanupAppender, and add the maxLogFileDays and baseFilePattern elements + /// to the configuration i.e.: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public class RollingFileCleanupAppender : RollingFileAppender + { + public int MaxLogFileDays { get; set; } + public string BaseFilePattern { get; set; } + + /// + /// This override will delete logs older than the specified amount of days + /// + /// + /// + protected override void OpenFile(string fileName, bool append) + { + bool cleanup = true; + // Validate settings and input + if (MaxLogFileDays <= 0) + { + LogLog.Warn(typeof(RollingFileCleanupAppender), "Parameter 'MaxLogFileDays' needs to be a positive integer, aborting cleanup"); + cleanup = false; + } + + if (string.IsNullOrWhiteSpace(BaseFilePattern)) + { + LogLog.Warn(typeof(RollingFileCleanupAppender), "Parameter 'BaseFilePattern' is empty, aborting cleanup"); + cleanup = false; + } + // grab the directory we are logging to, as this is were we will search for older logfiles + var logFolder = Path.GetDirectoryName(fileName); + if (Directory.Exists(logFolder) == false) + { + LogLog.Warn(typeof(RollingFileCleanupAppender), string.Format("Directory '{0}' for logfiles does not exist, aborting cleanup", logFolder)); + cleanup = false; + } + // If everything is validated, we can do the actual cleanup + if (cleanup) + { + Cleanup(logFolder); + } + + base.OpenFile(fileName, append); + } + + private void Cleanup(string directoryPath) + { + // only take files that matches the pattern we are using i.e. UmbracoTraceLog.*.txt.* + string[] logFiles = Directory.GetFiles(directoryPath, BaseFilePattern); + LogLog.Debug(typeof(RollingFileCleanupAppender), string.Format("Found {0} files that matches the baseFilePattern: '{1}'", logFiles.Length, BaseFilePattern)); + + foreach (var logFile in logFiles) + { + DateTime lastAccessTime = System.IO.File.GetLastWriteTimeUtc(logFile); + // take the value from the config file + if (lastAccessTime < DateTime.Now.AddDays(-MaxLogFileDays)) + { + LogLog.Debug(typeof(RollingFileCleanupAppender), string.Format("Deleting file {0} as its lastAccessTime is older than {1} days speficied by MaxLogFileDays", logFile, MaxLogFileDays)); + base.DeleteFile(logFile); + } + } + } + } +} diff --git a/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs b/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs index 4e2279caaf..711b7c9b9f 100644 --- a/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs +++ b/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs @@ -87,6 +87,15 @@ namespace Umbraco.Core.Models.Entities _currentChanges = null; } + /// + public virtual IEnumerable GetWereDirtyProperties() + { + // ReSharper disable once MergeConditionalExpression + return _savedChanges == null + ? Enumerable.Empty() + : _savedChanges.Where(x => x.Value).Select(x => x.Key); + } + #endregion #region Change Tracking diff --git a/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs b/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs index 75faba729b..e679b98b93 100644 --- a/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs +++ b/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs @@ -1,4 +1,6 @@ -namespace Umbraco.Core.Models.Entities +using System.Collections.Generic; + +namespace Umbraco.Core.Models.Entities { /// /// Defines an entity that tracks property changes and can be dirty, and remembers @@ -29,5 +31,10 @@ /// A value indicating whether to remember dirty properties. /// When is true, dirty properties are saved so they can be checked with WasDirty. void ResetDirtyProperties(bool rememberDirty); + + /// + /// Gets properties that were dirty. + /// + IEnumerable GetWereDirtyProperties(); } } diff --git a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs index 3de0d11de1..eab9e21013 100644 --- a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs @@ -60,6 +60,14 @@ namespace Umbraco.Core.Models.Identity private BackOfficeIdentityUser() { + _startMediaIds = new int[] { }; + _startContentIds = new int[] { }; + _groups = new IReadOnlyUserGroup[] { }; + _allowedSections = new string[] { }; + _culture = Configuration.GlobalSettings.DefaultUILanguage; + _groups = new IReadOnlyUserGroup[0]; + _roles = new ObservableCollection>(); + _roles.CollectionChanged += _roles_CollectionChanged; } /// diff --git a/src/Umbraco.Core/Models/Identity/IdentityProfile.cs b/src/Umbraco.Core/Models/Identity/IdentityProfile.cs index 46baad29b7..eef2a17aa5 100644 --- a/src/Umbraco.Core/Models/Identity/IdentityProfile.cs +++ b/src/Umbraco.Core/Models/Identity/IdentityProfile.cs @@ -46,20 +46,6 @@ namespace Umbraco.Core.Models.Identity dest.ResetDirtyProperties(true); dest.EnableChangeTracking(); }); - - CreateMap() - .ConstructUsing(source => new UserData(Guid.NewGuid().ToString("N"))) //this is the 'session id' - .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id)) - .ForMember(dest => dest.AllowedApplications, opt => opt.MapFrom(src => src.AllowedSections)) - .ForMember(dest => dest.Roles, opt => opt.MapFrom(src => new[] { src.Roles.Select(x => x.RoleId).ToArray()})) - .ForMember(dest => dest.RealName, opt => opt.MapFrom(src => src.Name)) - //When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups - .ForMember(dest => dest.StartContentNodes, opt => opt.MapFrom(src => src.CalculatedContentStartNodeIds)) - //When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups - .ForMember(dest => dest.StartMediaNodes, opt => opt.MapFrom(src => src.CalculatedMediaStartNodeIds)) - .ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.UserName)) - .ForMember(dest => dest.Culture, opt => opt.MapFrom(src => src.Culture)) - .ForMember(dest => dest.SessionId, opt => opt.MapFrom(src => src.SecurityStamp.IsNullOrWhiteSpace() ? Guid.NewGuid().ToString("N") : src.SecurityStamp)); } private static string GetPasswordHash(string storedPass) diff --git a/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs b/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs index a2f70ef5ef..12e874d5d7 100644 --- a/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs +++ b/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs @@ -17,7 +17,30 @@ namespace Umbraco.Core.Models.Membership } /// - /// Returns the aggregate permissions in the permission set + /// Returns the aggregate permissions in the permission set for a single node + /// + /// + /// + /// This value is only calculated once per node + /// + public IEnumerable GetAllPermissions(int entityId) + { + if (_aggregateNodePermissions == null) + _aggregateNodePermissions = new Dictionary(); + + string[] entityPermissions; + if (_aggregateNodePermissions.TryGetValue(entityId, out entityPermissions) == false) + { + entityPermissions = this.Where(x => x.EntityId == entityId).SelectMany(x => x.AssignedPermissions).Distinct().ToArray(); + _aggregateNodePermissions[entityId] = entityPermissions; + } + return entityPermissions; + } + + private Dictionary _aggregateNodePermissions; + + /// + /// Returns the aggregate permissions in the permission set for all nodes /// /// /// diff --git a/src/Umbraco.Core/Models/Membership/IUser.cs b/src/Umbraco.Core/Models/Membership/IUser.cs index dec0095243..8219af17b9 100644 --- a/src/Umbraco.Core/Models/Membership/IUser.cs +++ b/src/Umbraco.Core/Models/Membership/IUser.cs @@ -58,6 +58,11 @@ namespace Umbraco.Core.Models.Membership /// /// Will hold the media file system relative path of the users custom avatar if they uploaded one /// - string Avatar { get; set; } + string Avatar { get; set; } + + /// + /// A Json blob stored for recording tour data for a user + /// + string TourData { get; set; } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Membership/IUserGroup.cs b/src/Umbraco.Core/Models/Membership/IUserGroup.cs index de5842df61..508eb015ed 100644 --- a/src/Umbraco.Core/Models/Membership/IUserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/IUserGroup.cs @@ -3,7 +3,7 @@ using Umbraco.Core.Models.Entities; namespace Umbraco.Core.Models.Membership { - public interface IUserGroup : IEntity + public interface IUserGroup : IEntity, IRememberBeingDirty { string Alias { get; set; } diff --git a/src/Umbraco.Core/Models/Membership/MemberExportModel.cs b/src/Umbraco.Core/Models/Membership/MemberExportModel.cs new file mode 100644 index 0000000000..7153d380b4 --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/MemberExportModel.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Umbraco.Core.Models.Membership +{ + internal class MemberExportModel + { + public int Id { get; set; } + public Guid Key { get; set; } + public string Name { get; set; } + public string Username { get; set; } + public string Email { get; set; } + public List Groups { get; set; } + public string ContentTypeAlias { get; set; } + public DateTime CreateDate { get; set; } + public DateTime UpdateDate { get; set; } + public List Properties { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/Membership/MemberExportProperty.cs b/src/Umbraco.Core/Models/Membership/MemberExportProperty.cs new file mode 100644 index 0000000000..546d9255ea --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/MemberExportProperty.cs @@ -0,0 +1,14 @@ +using System; + +namespace Umbraco.Core.Models.Membership +{ + internal class MemberExportProperty + { + public int Id { get; set; } + public string Alias { get; set; } + public string Name { get; set; } + public object Value { get; set; } + public DateTime? CreateDate { get; set; } + public DateTime? UpdateDate { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index de410ffb9a..2dd750a353 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -100,6 +100,7 @@ namespace Umbraco.Core.Models.Membership private string _name; private string _securityStamp; private string _avatar; + private string _tourData; private int _sessionTimeout; private int[] _startContentIds; private int[] _startMediaIds; @@ -133,6 +134,7 @@ namespace Umbraco.Core.Models.Membership public readonly PropertyInfo SecurityStampSelector = ExpressionHelper.GetPropertyInfo(x => x.SecurityStamp); public readonly PropertyInfo AvatarSelector = ExpressionHelper.GetPropertyInfo(x => x.Avatar); + public readonly PropertyInfo TourDataSelector = ExpressionHelper.GetPropertyInfo(x => x.TourData); public readonly PropertyInfo SessionTimeoutSelector = ExpressionHelper.GetPropertyInfo(x => x.SessionTimeout); public readonly PropertyInfo StartContentIdSelector = ExpressionHelper.GetPropertyInfo(x => x.StartContentIds); public readonly PropertyInfo StartMediaIdSelector = ExpressionHelper.GetPropertyInfo(x => x.StartMediaIds); @@ -467,6 +469,16 @@ namespace Umbraco.Core.Models.Membership set { SetPropertyValueAndDetectChanges(value, ref _avatar, Ps.Value.AvatarSelector); } } + /// + /// A Json blob stored for recording tour data for a user + /// + [DataMember] + public string TourData + { + get { return _tourData; } + set { SetPropertyValueAndDetectChanges(value, ref _tourData, Ps.Value.TourDataSelector); } + } + /// /// Gets or sets the session timeout. /// @@ -671,5 +683,6 @@ namespace Umbraco.Core.Models.Membership return _user.GetHashCode(); } } + } } diff --git a/src/Umbraco.Core/Models/Membership/UserGroup.cs b/src/Umbraco.Core/Models/Membership/UserGroup.cs index ccd60f5861..db21c78438 100644 --- a/src/Umbraco.Core/Models/Membership/UserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/UserGroup.cs @@ -12,7 +12,7 @@ namespace Umbraco.Core.Models.Membership /// [Serializable] [DataContract(IsReference = true)] - internal class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup + public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup { private int? _startContentId; private int? _startMediaId; diff --git a/src/Umbraco.Core/ObjectExtensions.cs b/src/Umbraco.Core/ObjectExtensions.cs index 1c928730e2..4f48322a8f 100644 --- a/src/Umbraco.Core/ObjectExtensions.cs +++ b/src/Umbraco.Core/ObjectExtensions.cs @@ -6,10 +6,10 @@ using System.ComponentModel; using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; using System.Xml; using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Umbraco.Core.Composing; +using Umbraco.Core.Collections; namespace Umbraco.Core { @@ -18,8 +18,15 @@ namespace Umbraco.Core /// public static class ObjectExtensions { - private static readonly ConcurrentDictionary> ToObjectTypes - = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> ToObjectTypes = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary NullableGenericCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary InputTypeConverterCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary DestinationTypeConverterCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary AssignableTypeCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary BoolConvertCache = new ConcurrentDictionary(); + + private static readonly char[] NumberDecimalSeparatorsToNormalize = { '.', ',' }; + private static readonly CustomBooleanTypeConverter CustomBooleanTypeConverter = new CustomBooleanTypeConverter(); //private static readonly ConcurrentDictionary> ObjectFactoryCache = new ConcurrentDictionary>(); @@ -40,8 +47,8 @@ namespace Umbraco.Core /// public static void DisposeIfDisposable(this object input) { - var disposable = input as IDisposable; - if (disposable != null) disposable.Dispose(); + if (input is IDisposable disposable) + disposable.Dispose(); } /// @@ -53,347 +60,335 @@ namespace Umbraco.Core /// internal static T SafeCast(this object input) { - if (ReferenceEquals(null, input) || ReferenceEquals(default(T), input)) return default(T); - if (input is T) return (T)input; - return default(T); + if (ReferenceEquals(null, input) || ReferenceEquals(default(T), input)) return default; + if (input is T variable) return variable; + return default; } /// - /// Tries to convert the input object to the output type using TypeConverters + /// Attempts to convert the input object to the output type. /// - /// - /// - /// + /// This code is an optimized version of the original Umbraco method + /// The type to convert to + /// The input. + /// The public static Attempt TryConvertTo(this object input) { var result = TryConvertTo(input, typeof(T)); - if (result.Success == false) + + if (result.Success) + return Attempt.Succeed((T)result.Result); + + // just try to cast + try { - //just try a straight up conversion - try - { - var converted = (T) input; - return Attempt.Succeed(converted); - } - catch (Exception e) - { - return Attempt.Fail(e); - } + return Attempt.Succeed((T)input); + } + catch (Exception e) + { + return Attempt.Fail(e); } - return result.Success == false ? Attempt.Fail() : Attempt.Succeed((T)result.Result); } /// - /// Tries to convert the input object to the output type using TypeConverters. If the destination - /// type is a superclass of the input type, if will use . + /// Attempts to convert the input object to the output type. /// + /// This code is an optimized version of the original Umbraco method /// The input. - /// Type of the destination. - /// - public static Attempt TryConvertTo(this object input, Type destinationType) + /// The type to convert to + /// The + public static Attempt TryConvertTo(this object input, Type target) { - // if null... - if (input == null) + if (target == null) { - // nullable is ok - if (destinationType.IsGenericType && destinationType.GetGenericTypeDefinition() == typeof(Nullable<>)) - return Attempt.Succeed(null); - - // value type is nok, else can be null, so is ok - return Attempt.If(destinationType.IsValueType == false, null); + return Attempt.Fail(); } - // easy - if (destinationType == typeof(object)) return Attempt.Succeed(input); - if (input.GetType() == destinationType) return Attempt.Succeed(input); - - // check for string so that overloaders of ToString() can take advantage of the conversion. - if (destinationType == typeof(string)) return Attempt.Succeed(input.ToString()); - - // if we've got a nullable of something, we try to convert directly to that thing. - if (destinationType.IsGenericType && destinationType.GetGenericTypeDefinition() == typeof(Nullable<>)) + try { - var underlyingType = Nullable.GetUnderlyingType(destinationType); - - //special case for empty strings for bools/dates which should return null if an empty string - var asString = input as string; - if (asString != null && string.IsNullOrEmpty(asString) && (underlyingType == typeof(DateTime) || underlyingType == typeof(bool))) + if (input == null) { - return Attempt.Succeed(null); + // Nullable is ok + if (target.IsGenericType && GetCachedGenericNullableType(target) != null) + { + return Attempt.Succeed(null); + } + + // Reference types are ok + return Attempt.If(target.IsValueType == false, null); } - // recursively call into myself with the inner (not-nullable) type and handle the outcome - var nonNullable = input.TryConvertTo(underlyingType); + var inputType = input.GetType(); - // and if sucessful, fall on through to rewrap in a nullable; if failed, pass on the exception - if (nonNullable.Success) - input = nonNullable.Result; // now fall on through... + // Easy + if (target == typeof(object) || inputType == target) + { + return Attempt.Succeed(input); + } + + // Check for string so that overloaders of ToString() can take advantage of the conversion. + if (target == typeof(string)) + { + return Attempt.Succeed(input.ToString()); + } + + // If we've got a nullable of something, we try to convert directly to that thing. + // We cache the destination type and underlying nullable types + // Any other generic types need to fall through + if (target.IsGenericType) + { + var underlying = GetCachedGenericNullableType(target); + if (underlying != null) + { + // Special case for empty strings for bools/dates which should return null if an empty string. + if (input is string inputString) + { + //TODO: Why the check against only bool/date when a string is null/empty? In what scenario can we convert to another type when the string is null or empty other than just being null? + if (string.IsNullOrEmpty(inputString) && (underlying == typeof(DateTime) || underlying == typeof(bool))) + { + return Attempt.Succeed(null); + } + } + + // Recursively call into this method with the inner (not-nullable) type and handle the outcome + var inner = input.TryConvertTo(underlying); + + // And if sucessful, fall on through to rewrap in a nullable; if failed, pass on the exception + if (inner.Success) + { + input = inner.Result; // Now fall on through... + } + else + { + return Attempt.Fail(inner.Exception); + } + } + } else - return Attempt.Fail(nonNullable.Exception); - } - - // we've already dealed with nullables, so any other generic types need to fall through - if (destinationType.IsGenericType == false) - { - if (input is string) { - // try convert from string, returns an Attempt if the string could be - // processed (either succeeded or failed), else null if we need to try - // other methods - var result = TryConvertToFromString(input as string, destinationType); - if (result.HasValue) return result.Value; - } + // target is not a generic type - //TODO: Do a check for destination type being IEnumerable and source type implementing IEnumerable with - // the same 'T', then we'd have to find the extension method for the type AsEnumerable() and execute it. - - if (TypeHelper.IsTypeAssignableFrom(destinationType, input.GetType()) - && TypeHelper.IsTypeAssignableFrom(input)) - { - try + if (input is string inputString) { - var casted = Convert.ChangeType(input, destinationType); - return Attempt.Succeed(casted); + // Try convert from string, returns an Attempt if the string could be + // processed (either succeeded or failed), else null if we need to try + // other methods + var result = TryConvertToFromString(inputString, target); + if (result.HasValue) + { + return result.Value; + } } - catch (Exception e) + + // TODO: Do a check for destination type being IEnumerable and source type implementing IEnumerable with + // the same 'T', then we'd have to find the extension method for the type AsEnumerable() and execute it. + if (GetCachedCanAssign(input, inputType, target)) { - return Attempt.Fail(e); + return Attempt.Succeed(Convert.ChangeType(input, target)); } } - } - var inputConverter = TypeDescriptor.GetConverter(input); - if (inputConverter.CanConvertTo(destinationType)) - { - try + if (target == typeof(bool)) { - var converted = inputConverter.ConvertTo(input, destinationType); - return Attempt.Succeed(converted); - } - catch (Exception e) - { - return Attempt.Fail(e); - } - } - - if (destinationType == typeof(bool)) - { - var boolConverter = new CustomBooleanTypeConverter(); - if (boolConverter.CanConvertFrom(input.GetType())) - { - try + if (GetCachedCanConvertToBoolean(inputType)) { - var converted = boolConverter.ConvertFrom(input); - return Attempt.Succeed(converted); - } - catch (Exception e) - { - return Attempt.Fail(e); + return Attempt.Succeed(CustomBooleanTypeConverter.ConvertFrom(input)); } } - } - var outputConverter = TypeDescriptor.GetConverter(destinationType); - if (outputConverter.CanConvertFrom(input.GetType())) - { - try + var inputConverter = GetCachedSourceTypeConverter(inputType, target); + if (inputConverter != null) { - var converted = outputConverter.ConvertFrom(input); - return Attempt.Succeed(converted); + return Attempt.Succeed(inputConverter.ConvertTo(input, target)); } - catch (Exception e) + + var outputConverter = GetCachedTargetTypeConverter(inputType, target); + if (outputConverter != null) { - return Attempt.Fail(e); + return Attempt.Succeed(outputConverter.ConvertFrom(input)); + } + + if (target.IsGenericType && GetCachedGenericNullableType(target) != null) + { + // cannot Convert.ChangeType as that does not work with nullable + // input has already been converted to the underlying type - just + // return input, there's an implicit conversion from T to T? anyways + return Attempt.Succeed(input); + } + + // Re-check convertables since we altered the input through recursion + if (input is IConvertible convertible2) + { + return Attempt.Succeed(Convert.ChangeType(convertible2, target)); } } - - if (TypeHelper.IsTypeAssignableFrom(input)) + catch (Exception e) { - try - { - var casted = Convert.ChangeType(input, destinationType); - return Attempt.Succeed(casted); - } - catch (Exception e) - { - return Attempt.Fail(e); - } + return Attempt.Fail(e); } return Attempt.Fail(); } - // returns an attempt if the string has been processed (either succeeded or failed) - // returns null if we need to try other methods - private static Attempt? TryConvertToFromString(this string input, Type destinationType) + /// + /// Attempts to convert the input string to the output type. + /// + /// This code is an optimized version of the original Umbraco method + /// The input. + /// The type to convert to + /// The + private static Attempt? TryConvertToFromString(this string input, Type target) { - // easy - if (destinationType == typeof(string)) - return Attempt.Succeed(input); - - // null, empty, whitespaces - if (string.IsNullOrWhiteSpace(input)) + // Easy + if (target == typeof(string)) { - if (destinationType == typeof(bool)) // null/empty = bool false - return Attempt.Succeed(false); - if (destinationType == typeof(DateTime)) // null/empty = min DateTime value - return Attempt.Succeed(DateTime.MinValue); - - // cannot decide here, - // any of the types below will fail parsing and will return a failed attempt - // but anything else will not be processed and will return null - // so even though the string is null/empty we have to proceed + return Attempt.Succeed(input); } - // look for type conversions in the expected order of frequency of use... - if (destinationType.IsPrimitive) + // Null, empty, whitespaces + if (string.IsNullOrWhiteSpace(input)) { - if (destinationType == typeof(int)) // aka Int32 + if (target == typeof(bool)) { - int value; - if (int.TryParse(input, out value)) return Attempt.Succeed(value); + // null/empty = bool false + return Attempt.Succeed(false); + } - // because decimal 100.01m will happily convert to integer 100, it + if (target == typeof(DateTime)) + { + // null/empty = min DateTime value + return Attempt.Succeed(DateTime.MinValue); + } + + // Cannot decide here, + // Any of the types below will fail parsing and will return a failed attempt + // but anything else will not be processed and will return null + // so even though the string is null/empty we have to proceed. + } + + // Look for type conversions in the expected order of frequency of use. + // + // By using a mixture of ordered if statements and switches we can optimize both for + // fast conditional checking for most frequently used types and the branching + // that does not depend on previous values available to switch statements. + if (target.IsPrimitive) + { + if (target == typeof(int)) + { + if (int.TryParse(input, out var value)) + { + return Attempt.Succeed(value); + } + + // Because decimal 100.01m will happily convert to integer 100, it // makes sense that string "100.01" *also* converts to integer 100. - decimal value2; var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(decimal.TryParse(input2, out value2), Convert.ToInt32(value2)); + return Attempt.If(decimal.TryParse(input2, out var value2), Convert.ToInt32(value2)); } - if (destinationType == typeof(long)) // aka Int64 + if (target == typeof(long)) { - long value; - if (long.TryParse(input, out value)) return Attempt.Succeed(value); + if (long.TryParse(input, out var value)) + { + return Attempt.Succeed(value); + } - // same as int - decimal value2; + // Same as int var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(decimal.TryParse(input2, out value2), Convert.ToInt64(value2)); + return Attempt.If(decimal.TryParse(input2, out var value2), Convert.ToInt64(value2)); } - // fixme - should we do the decimal trick for short, byte, unsigned? + // TODO: Should we do the decimal trick for short, byte, unsigned? - if (destinationType == typeof(bool)) // aka Boolean + if (target == typeof(bool)) { - bool value; - if (bool.TryParse(input, out value)) return Attempt.Succeed(value); - // don't declare failure so the CustomBooleanTypeConverter can try + if (bool.TryParse(input, out var value)) + { + return Attempt.Succeed(value); + } + + // Don't declare failure so the CustomBooleanTypeConverter can try return null; } - if (destinationType == typeof(short)) // aka Int16 + // Calling this method directly is faster than any attempt to cache it. + switch (Type.GetTypeCode(target)) { - short value; - return Attempt.If(short.TryParse(input, out value), value); - } + case TypeCode.Int16: + return Attempt.If(short.TryParse(input, out var value), value); - if (destinationType == typeof(double)) // aka Double - { - double value; - var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(double.TryParse(input2, out value), value); - } + case TypeCode.Double: + var input2 = NormalizeNumberDecimalSeparator(input); + return Attempt.If(double.TryParse(input2, out var valueD), valueD); - if (destinationType == typeof(float)) // aka Single - { - float value; - var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(float.TryParse(input2, out value), value); - } + case TypeCode.Single: + var input3 = NormalizeNumberDecimalSeparator(input); + return Attempt.If(float.TryParse(input3, out var valueF), valueF); - if (destinationType == typeof(char)) // aka Char - { - char value; - return Attempt.If(char.TryParse(input, out value), value); - } + case TypeCode.Char: + return Attempt.If(char.TryParse(input, out var valueC), valueC); - if (destinationType == typeof(byte)) // aka Byte - { - byte value; - return Attempt.If(byte.TryParse(input, out value), value); - } + case TypeCode.Byte: + return Attempt.If(byte.TryParse(input, out var valueB), valueB); - if (destinationType == typeof(sbyte)) // aka SByte - { - sbyte value; - return Attempt.If(sbyte.TryParse(input, out value), value); - } + case TypeCode.SByte: + return Attempt.If(sbyte.TryParse(input, out var valueSb), valueSb); - if (destinationType == typeof(uint)) // aka UInt32 - { - uint value; - return Attempt.If(uint.TryParse(input, out value), value); - } + case TypeCode.UInt32: + return Attempt.If(uint.TryParse(input, out var valueU), valueU); - if (destinationType == typeof(ushort)) // aka UInt16 - { - ushort value; - return Attempt.If(ushort.TryParse(input, out value), value); - } + case TypeCode.UInt16: + return Attempt.If(ushort.TryParse(input, out var valueUs), valueUs); - if (destinationType == typeof(ulong)) // aka UInt64 - { - ulong value; - return Attempt.If(ulong.TryParse(input, out value), value); + case TypeCode.UInt64: + return Attempt.If(ulong.TryParse(input, out var valueUl), valueUl); } } - else if (destinationType == typeof(Guid)) + else if (target == typeof(Guid)) { - Guid value; - return Attempt.If(Guid.TryParse(input, out value), value); + return Attempt.If(Guid.TryParse(input, out var value), value); } - else if (destinationType == typeof(DateTime)) + else if (target == typeof(DateTime)) { - DateTime value; - if (DateTime.TryParse(input, out value)) + if (DateTime.TryParse(input, out var value)) { switch (value.Kind) { case DateTimeKind.Unspecified: case DateTimeKind.Utc: return Attempt.Succeed(value); + case DateTimeKind.Local: return Attempt.Succeed(value.ToUniversalTime()); + default: throw new ArgumentOutOfRangeException(); } } + return Attempt.Fail(); } - else if (destinationType == typeof(DateTimeOffset)) + else if (target == typeof(DateTimeOffset)) { - DateTimeOffset value; - return Attempt.If(DateTimeOffset.TryParse(input, out value), value); + return Attempt.If(DateTimeOffset.TryParse(input, out var value), value); } - else if (destinationType == typeof(TimeSpan)) + else if (target == typeof(TimeSpan)) { - TimeSpan value; - return Attempt.If(TimeSpan.TryParse(input, out value), value); + return Attempt.If(TimeSpan.TryParse(input, out var value), value); } - else if (destinationType == typeof(decimal)) // aka Decimal + else if (target == typeof(decimal)) { - decimal value; var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(decimal.TryParse(input2, out value), value); + return Attempt.If(decimal.TryParse(input2, out var value), value); } - else if (destinationType == typeof(Version)) + else if (input != null && target == typeof(Version)) { - Version value; - return Attempt.If(Version.TryParse(input, out value), value); + return Attempt.If(Version.TryParse(input, out var value), value); } + // E_NOTIMPL IPAddress, BigInteger - - return null; // we can't decide... + return null; // we can't decide... } - - private static readonly char[] NumberDecimalSeparatorsToNormalize = {'.', ','}; - - private static string NormalizeNumberDecimalSeparator(string s) - { - var normalized = System.Threading.Thread.CurrentThread.CurrentCulture.NumberFormat.NumberDecimalSeparator[0]; - return s.ReplaceMany(NumberDecimalSeparatorsToNormalize, normalized); - } - internal static void CheckThrowObjectDisposed(this IDisposable disposable, bool isDisposed, string objectname) { //TODO: Localise this exception @@ -475,8 +470,7 @@ namespace Umbraco.Core /// /// /// - public static IDictionary ToDictionary(this T o, - params Expression>[] ignoreProperties) + public static IDictionary ToDictionary(this T o, params Expression>[] ignoreProperties) { return o.ToDictionary(ignoreProperties.Select(e => o.GetPropertyInfo(e)).Select(propInfo => propInfo.Name).ToArray()); } @@ -563,7 +557,7 @@ namespace Umbraco.Core var items = (from object enumItem in enumerable let value = GetEnumPropertyDebugString(enumItem, levels) where value != null select value).Take(10).ToList(); return items.Any() - ? "{{ {0} }}".InvariantFormat(String.Join(", ", items)) + ? "{{ {0} }}".InvariantFormat(string.Join(", ", items)) : null; } @@ -585,9 +579,9 @@ namespace Umbraco.Core { var items = (from propertyInfo in props - let value = GetPropertyDebugString(propertyInfo, obj, levels) - where value != null - select "{0}={1}".InvariantFormat(propertyInfo.Name, value)).ToArray(); + let value = GetPropertyDebugString(propertyInfo, obj, levels) + where value != null + select "{0}={1}".InvariantFormat(propertyInfo.Name, value)).ToArray(); return items.Any() ? "[{0}]:{{ {1} }}".InvariantFormat(obj.GetType().Name, String.Join(", ", items)) @@ -690,7 +684,109 @@ namespace Umbraco.Core internal static Guid AsGuid(this object value) { - return value is Guid ? (Guid) value : Guid.Empty; + return value is Guid guid ? guid : Guid.Empty; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string NormalizeNumberDecimalSeparator(string s) + { + var normalized = System.Threading.Thread.CurrentThread.CurrentCulture.NumberFormat.NumberDecimalSeparator[0]; + return s.ReplaceMany(NumberDecimalSeparatorsToNormalize, normalized); + } + + // gets a converter for source, that can convert to target, or null if none exists + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TypeConverter GetCachedSourceTypeConverter(Type source, Type target) + { + var key = new CompositeTypeTypeKey(source, target); + + if (InputTypeConverterCache.TryGetValue(key, out var typeConverter)) + { + return typeConverter; + } + + var converter = TypeDescriptor.GetConverter(source); + if (converter.CanConvertTo(target)) + { + return InputTypeConverterCache[key] = converter; + } + + return InputTypeConverterCache[key] = null; + } + + // gets a converter for target, that can convert from source, or null if none exists + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TypeConverter GetCachedTargetTypeConverter(Type source, Type target) + { + var key = new CompositeTypeTypeKey(source, target); + + if (DestinationTypeConverterCache.TryGetValue(key, out var typeConverter)) + { + return typeConverter; + } + + TypeConverter converter = TypeDescriptor.GetConverter(target); + if (converter.CanConvertFrom(source)) + { + return DestinationTypeConverterCache[key] = converter; + } + + return DestinationTypeConverterCache[key] = null; + } + + // gets the underlying type of a nullable type, or null if the type is not nullable + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Type GetCachedGenericNullableType(Type type) + { + if (NullableGenericCache.TryGetValue(type, out var underlyingType)) + { + return underlyingType; + } + + if (type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + Type underlying = Nullable.GetUnderlyingType(type); + return NullableGenericCache[type] = underlying; + } + + return NullableGenericCache[type] = null; + } + + // gets an IConvertible from source to target type, or null if none exists + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool GetCachedCanAssign(object input, Type source, Type target) + { + var key = new CompositeTypeTypeKey(source, target); + if (AssignableTypeCache.TryGetValue(key, out var canConvert)) + { + return canConvert; + } + + // "object is" is faster than "Type.IsAssignableFrom. + // We can use it to very quickly determine whether true/false + if (input is IConvertible && target.IsAssignableFrom(source)) + { + return AssignableTypeCache[key] = true; + } + + return AssignableTypeCache[key] = false; + } + + // determines whether a type can be converted to boolean + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool GetCachedCanConvertToBoolean(Type type) + { + if (BoolConvertCache.TryGetValue(type, out var result)) + { + return result; + } + + if (CustomBooleanTypeConverter.CanConvertFrom(type)) + { + return BoolConvertCache[type] = true; + } + + return BoolConvertCache[type] = false; } } } diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 43438bfcf1..68f9435219 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -71,6 +71,10 @@ namespace Umbraco.Core public const string TaskType = /*TableNamePrefix*/ "cms" + "TaskType"; public const string KeyValue = TableNamePrefix + "KeyValue"; + + public const string AuditEntry = /*TableNamePrefix*/ "umbraco" + "Audit"; + public const string Consent = /*TableNamePrefix*/ "umbraco" + "Consent"; + public const string UserLogin = /*TableNamePrefix*/ "umbraco" + "UserLogin"; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/AuditEntryDto.cs b/src/Umbraco.Core/Persistence/Dtos/AuditEntryDto.cs new file mode 100644 index 0000000000..27eeef8e56 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/AuditEntryDto.cs @@ -0,0 +1,59 @@ +using System; +using NPoco; +using Umbraco.Core.Persistence.DatabaseAnnotations; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Core.Persistence.Dtos +{ + [TableName(Constants.DatabaseSchema.Tables.AuditEntry)] + [PrimaryKey("id")] + [ExplicitColumns] + internal class AuditEntryDto + { + public const int IpLength = 64; + public const int EventTypeLength = 256; + public const int DetailsLength = 1024; + + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + // there is NO foreign key to the users table here, neither for performing user nor for + // affected user, so we can delete users and NOT delete the associated audit trails, and + // users can still be identified via the details free-form text fields. + + [Column("performingUserId")] + public int PerformingUserId { get; set; } + + [Column("performingDetails")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(DetailsLength)] + public string PerformingDetails { get; set; } + + [Column("performingIp")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(IpLength)] + public string PerformingIp { get; set; } + + [Column("eventDateUtc")] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime EventDateUtc { get; set; } + + [Column("affectedUserId")] + public int AffectedUserId { get; set; } + + [Column("affectedDetails")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(DetailsLength)] + public string AffectedDetails { get; set; } + + [Column("eventType")] + [Length(EventTypeLength)] + public string EventType { get; set; } + + [Column("eventDetails")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(DetailsLength)] + public string EventDetails { get; set; } + } +} diff --git a/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs b/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs index aeabbfa61b..1fc5eb90a8 100644 --- a/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs @@ -27,5 +27,10 @@ namespace Umbraco.Core.Persistence.Dtos [NullSetting(NullSetting = NullSettings.NotNull)] [Length(500)] public string OriginIdentity { get; set; } + + [Column("instructionCount")] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = 1)] + public int InstructionCount { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/ConsentDto.cs b/src/Umbraco.Core/Persistence/Dtos/ConsentDto.cs new file mode 100644 index 0000000000..763df352de --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/ConsentDto.cs @@ -0,0 +1,42 @@ +using System; +using NPoco; +using Umbraco.Core.Persistence.DatabaseAnnotations; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Core.Persistence.Dtos +{ + [TableName(Constants.DatabaseSchema.Tables.Consent)] + [PrimaryKey("id")] + [ExplicitColumns] + public class ConsentDto + { + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + [Column("current")] + public bool Current { get; set; } + + [Column("source")] + [Length(512)] + public string Source { get; set; } + + [Column("context")] + [Length(128)] + public string Context { get; set; } + + [Column("action")] + [Length(512)] + public string Action { get; set; } + + [Column("createDate")] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime CreateDate { get; set; } + + [Column("state")] + public int State { get; set; } + + [Column("comment")] + public string Comment { get; set; } + } +} diff --git a/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs b/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs index 5ff68ab834..545f92bb82 100644 --- a/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs @@ -27,5 +27,9 @@ namespace Umbraco.Core.Persistence.Dtos [Column("viewOnProfile")] [Constraint(Default = "0")] public bool ViewOnProfile { get; set; } + + [Column("isSensitive")] + [Constraint(Default = "0")] + public bool IsSensitive { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs index e305d6480e..8c52aa1e15 100644 --- a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs @@ -27,6 +27,7 @@ namespace Umbraco.Core.Persistence.Dtos [ForeignKey(typeof(PropertyTypeGroupDto))] public int? PropertyTypeGroupId { get; set; } + [Index(IndexTypes.NonClustered, Name = "IX_cmsPropertyTypeAlias")] [Column("Alias")] public string Alias { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs index 88c02862e2..c68dee42b5 100644 --- a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs @@ -45,6 +45,9 @@ namespace Umbraco.Core.Persistence.Dtos [Column("viewOnProfile")] public bool ViewOnProfile { get; set; } + [Column("isSensitive")] + public bool IsSensitive { get; set; } + /* DataType */ [Column("propertyEditorAlias")] public string PropertyEditorAlias { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/UserDto.cs b/src/Umbraco.Core/Persistence/Dtos/UserDto.cs index b378747a4e..c6cc889ab0 100644 --- a/src/Umbraco.Core/Persistence/Dtos/UserDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/UserDto.cs @@ -104,6 +104,14 @@ namespace Umbraco.Core.Persistence.Dtos [Length(500)] public string Avatar { get; set; } + /// + /// A Json blob stored for recording tour data for a user + /// + [Column("tourData")] + [NullSetting(NullSetting = NullSettings.Null)] + [SpecialDbType(SpecialDbTypes.NTEXT)] + public string TourData { get; set; } + [ResultColumn] [Reference(ReferenceType.Many, ReferenceMemberName = "UserId")] public List UserGroupDtos { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs b/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs index 0479f36878..3383ed9e3d 100644 --- a/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs @@ -17,7 +17,7 @@ namespace Umbraco.Core.Persistence.Dtos } [Column("id")] - [PrimaryKeyColumn(IdentitySeed = 5)] + [PrimaryKeyColumn(IdentitySeed = 6)] public int Id { get; set; } [Column("userGroupAlias")] diff --git a/src/Umbraco.Core/Persistence/Dtos/UserLoginDto.cs b/src/Umbraco.Core/Persistence/Dtos/UserLoginDto.cs new file mode 100644 index 0000000000..86d306b06a --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/UserLoginDto.cs @@ -0,0 +1,52 @@ +using System; +using NPoco; +using Umbraco.Core.Persistence.DatabaseAnnotations; + +namespace Umbraco.Core.Persistence.Dtos +{ + [TableName(Constants.DatabaseSchema.Tables.UserLogin)] + [PrimaryKey("sessionId", AutoIncrement = false)] + [ExplicitColumns] + internal class UserLoginDto + { + [Column("sessionId")] + [PrimaryKeyColumn(AutoIncrement = false)] + public Guid SessionId { get; set; } + + [Column("userId")] + [ForeignKey(typeof(UserDto), Name = "FK_" + Constants.DatabaseSchema.Tables.UserLogin + "_umbracoUser_id")] + public int UserId { get; set; } + + /// + /// Tracks when the session is created + /// + [Column("loggedInUtc")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public DateTime LoggedInUtc { get; set; } + + /// + /// Updated every time a user's session is validated + /// + /// + /// This allows us to guess if a session is timed out if a user doesn't actively log out + /// and also allows us to trim the data in the table + /// + [Column("lastValidatedUtc")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public DateTime LastValidatedUtc { get; set; } + + /// + /// Tracks when the session is removed when the user's account is logged out + /// + [Column("loggedOutUtc")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LoggedOutUtc { get; set; } + + /// + /// Logs the IP address of the session if available + /// + [Column("ipAddress")] + [NullSetting(NullSetting = NullSettings.Null)] + public string IpAddress { get; set; } + } +} diff --git a/src/Umbraco.Core/Properties/AssemblyInfo.cs b/src/Umbraco.Core/Properties/AssemblyInfo.cs index c4169147f8..5e411e681c 100644 --- a/src/Umbraco.Core/Properties/AssemblyInfo.cs +++ b/src/Umbraco.Core/Properties/AssemblyInfo.cs @@ -33,5 +33,8 @@ using System.Runtime.InteropServices; [assembly: InternalsVisibleTo("Umbraco.Forms.Core.Providers")] [assembly: InternalsVisibleTo("Umbraco.Forms.Web")] +// Umbraco Headless +[assembly: InternalsVisibleTo("Umbraco.Headless")] + // v8 [assembly: InternalsVisibleTo("Umbraco.Compat7")] diff --git a/src/Umbraco.Core/RuntimeState.cs b/src/Umbraco.Core/RuntimeState.cs index e4f934a60c..3c3ebdbd84 100644 --- a/src/Umbraco.Core/RuntimeState.cs +++ b/src/Umbraco.Core/RuntimeState.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Web; using Semver; @@ -18,6 +19,7 @@ namespace Umbraco.Core private readonly ILogger _logger; private readonly Lazy _serverRegistrar; private readonly Lazy _mainDom; + private readonly HashSet _applicationUrls = new HashSet(); private RuntimeLevel _level; /// @@ -99,7 +101,18 @@ namespace Umbraco.Core /// internal void EnsureApplicationUrl(HttpRequestBase request = null, IUmbracoSettingsSection settings = null) { - if (ApplicationUrl != null) return; + // see U4-10626 - in some cases we want to reset the application url + // (this is a simplified version of what was in 7.x) + // note: should this be optional? is it expensive? + var url = request == null ? null : ApplicationUrlHelper.GetApplicationUrlFromCurrentRequest(request); + var change = url != null && !_applicationUrls.Contains(url); + if (change) + { + _logger.Info(typeof(ApplicationUrlHelper), $"New url \"{url}\" detected, re-discovering application url."); + _applicationUrls.Add(url); + } + + if (ApplicationUrl != null && !change) return; ApplicationUrl = new Uri(ApplicationUrlHelper.GetApplicationUrl(_logger, request, settings)); } diff --git a/src/Umbraco.Core/Security/AuthenticationExtensions.cs b/src/Umbraco.Core/Security/AuthenticationExtensions.cs index 294b8bbd73..eff8b1f958 100644 --- a/src/Umbraco.Core/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Core/Security/AuthenticationExtensions.cs @@ -11,6 +11,7 @@ using System.Security.Principal; using System.Threading; using System.Web; using System.Web.Security; +using Microsoft.AspNet.Identity; using AutoMapper; using Microsoft.Owin; using Newtonsoft.Json; @@ -18,6 +19,7 @@ using Umbraco.Core.Configuration; using Umbraco.Core.Composing; using Umbraco.Core.Models.Membership; using Umbraco.Core.Logging; +using IUser = Umbraco.Core.Models.Membership.IUser; namespace Umbraco.Core.Security { @@ -224,8 +226,11 @@ namespace Umbraco.Core.Security /// public static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContextBase http, UserData userdata) { + //ONLY used by BasePage.doLogin! + if (http == null) throw new ArgumentNullException("http"); - if (userdata == null) throw new ArgumentNullException("userdata"); + if (userdata == null) throw new ArgumentNullException("userdata"); + var userDataString = JsonConvert.SerializeObject(userdata); return CreateAuthTicketAndCookie( http, @@ -237,14 +242,7 @@ namespace Umbraco.Core.Security 1440, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain); - } - - internal static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContext http, UserData userdata) - { - if (http == null) throw new ArgumentNullException("http"); - if (userdata == null) throw new ArgumentNullException("userdata"); - return new HttpContextWrapper(http).CreateUmbracoAuthTicket(userdata); - } + } /// /// returns the number of seconds the user has until their auth session times out @@ -314,7 +312,23 @@ namespace Umbraco.Core.Security /// /// private static void Logout(this HttpContextBase http, string cookieName) - { + { + //We need to clear the sessionId from the database. This is legacy code to do any logging out and shouldn't really be used at all but in any case + //we need to make sure the session is cleared. Due to the legacy nature of this it means we need to use singletons + if (http.User != null) + { + var claimsIdentity = http.User.Identity as ClaimsIdentity; + if (claimsIdentity != null) + { + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + Guid guidSession; + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) + { + ApplicationContext.Current.Services.UserService.ClearLoginSession(guidSession); + } + } + } + if (http == null) throw new ArgumentNullException("http"); //clear the preview cookie and external login var cookies = new[] { cookieName, Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName }; diff --git a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs index 86c9e17447..4df824544c 100644 --- a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs +++ b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs @@ -2,16 +2,29 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using System.Web; using Microsoft.AspNet.Identity; using Umbraco.Core.Models.Identity; +using Umbraco.Core.Services; namespace Umbraco.Core.Security { public class BackOfficeClaimsIdentityFactory : ClaimsIdentityFactory where T: BackOfficeIdentityUser { + private readonly ApplicationContext _appCtx; + + [Obsolete("Use the overload specifying all dependencies instead")] public BackOfficeClaimsIdentityFactory() + :this(ApplicationContext.Current) { + } + + public BackOfficeClaimsIdentityFactory(ApplicationContext appCtx) + { + if (appCtx == null) throw new ArgumentNullException("appCtx"); + _appCtx = appCtx; + SecurityStampClaimType = Constants.Security.SessionIdClaimType; UserNameClaimType = ClaimTypes.Name; } @@ -24,9 +37,9 @@ namespace Umbraco.Core.Security public override async Task CreateAsync(UserManager manager, T user, string authenticationType) { var baseIdentity = await base.CreateAsync(manager, user, authenticationType); - + var umbracoIdentity = new UmbracoBackOfficeIdentity(baseIdentity, - //set a new session id + //NOTE - there is no session id assigned here, this is just creating the identity, a session id will be generated when the cookie is written new UserData { Id = user.Id, @@ -37,15 +50,22 @@ namespace Umbraco.Core.Security Roles = user.Roles.Select(x => x.RoleId).ToArray(), StartContentNodes = user.CalculatedContentStartNodeIds, StartMediaNodes = user.CalculatedMediaStartNodeIds, - SessionId = user.SecurityStamp + SecurityStamp = user.SecurityStamp }); return umbracoIdentity; - } + } } public class BackOfficeClaimsIdentityFactory : BackOfficeClaimsIdentityFactory { + [Obsolete("Use the overload specifying all dependencies instead")] + public BackOfficeClaimsIdentityFactory() + { + } + public BackOfficeClaimsIdentityFactory(ApplicationContext appCtx) : base(appCtx) + { + } } } diff --git a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs index 9b0b483421..58543f106c 100644 --- a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs @@ -1,21 +1,83 @@ using System; +using System.Collections.Concurrent; +using System.ComponentModel; using System.Globalization; using System.Net.Http.Headers; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; +using Semver; using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Identity; namespace Umbraco.Core.Security { public class BackOfficeCookieAuthenticationProvider : CookieAuthenticationProvider { + private readonly ApplicationContext _appCtx; + + [Obsolete("Use the ctor specifying all dependencies")] + [EditorBrowsable(EditorBrowsableState.Never)] + public BackOfficeCookieAuthenticationProvider() + : this(ApplicationContext.Current) + { + } + + public BackOfficeCookieAuthenticationProvider(ApplicationContext appCtx) + { + if (appCtx == null) throw new ArgumentNullException("appCtx"); + _appCtx = appCtx; + } + + private static readonly SemVersion MinUmbracoVersionSupportingLoginSessions = new SemVersion(7, 8); + + public override void ResponseSignIn(CookieResponseSignInContext context) + { + var backOfficeIdentity = context.Identity as UmbracoBackOfficeIdentity; + if (backOfficeIdentity != null) + { + //generate a session id and assign it + //create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one + + //NOTE - special check because when we are upgrading to 7.8 we cannot create a session since the db isn't ready and we'll get exceptions + var canAcquireSession = _appCtx.IsUpgrading == false || _appCtx.CurrentVersion() >= MinUmbracoVersionSupportingLoginSessions; + + var session = canAcquireSession + ? _appCtx.Services.UserService.CreateLoginSession((int)backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) + : Guid.NewGuid(); + + backOfficeIdentity.UserData.SessionId = session.ToString(); + } + + base.ResponseSignIn(context); + } + public override void ResponseSignOut(CookieResponseSignOutContext context) { + //Clear the user's session on sign out + if (context != null && context.OwinContext != null && context.OwinContext.Authentication != null + && context.OwinContext.Authentication.User != null && context.OwinContext.Authentication.User.Identity != null) + { + var claimsIdentity = context.OwinContext.Authentication.User.Identity as ClaimsIdentity; + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + Guid guidSession; + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) + { + _appCtx.Services.UserService.ClearLoginSession(guidSession); + } + } + base.ResponseSignOut(context); //Make sure the definitely all of these cookies are cleared when signing out with cookies + context.Response.Cookies.Append(SessionIdValidator.CookieName, "", new CookieOptions + { + Expires = DateTime.Now.AddYears(-1), + Path = "/" + }); context.Response.Cookies.Append(UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, "", new CookieOptions { Expires = DateTime.Now.AddYears(-1), @@ -34,21 +96,45 @@ namespace Umbraco.Core.Security } /// - /// Ensures that the culture is set correctly for the current back office user + /// Ensures that the culture is set correctly for the current back office user and that the user's session token is valid /// /// /// - public override Task ValidateIdentity(CookieValidateIdentityContext context) + public override async Task ValidateIdentity(CookieValidateIdentityContext context) + { + EnsureCulture(context); + + await EnsureValidSessionId(context); + + await base.ValidateIdentity(context); + } + + /// + /// Ensures that the user has a valid session id + /// + /// + /// So that we are not overloading the database this throttles it's check to every minute + /// + protected virtual async Task EnsureValidSessionId(CookieValidateIdentityContext context) + { + if (_appCtx.IsConfigured && _appCtx.IsUpgrading == false) + await SessionIdValidator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context); + } + + private void EnsureCulture(CookieValidateIdentityContext context) { var umbIdentity = context.Identity as UmbracoBackOfficeIdentity; if (umbIdentity != null && umbIdentity.IsAuthenticated) { Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = - new CultureInfo(umbIdentity.Culture); + UserCultures.GetOrAdd(umbIdentity.Culture, s => new CultureInfo(s)); } - - return base.ValidateIdentity(context); } + + /// + /// Used so that we aren't creating a new CultureInfo object for every single request + /// + private static readonly ConcurrentDictionary UserCultures = new ConcurrentDictionary(); } } diff --git a/src/Umbraco.Core/Security/BackOfficeSignInManager.cs b/src/Umbraco.Core/Security/BackOfficeSignInManager.cs index a6c0ae6b49..0362ab00b8 100644 --- a/src/Umbraco.Core/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Core/Security/BackOfficeSignInManager.cs @@ -12,6 +12,7 @@ using Umbraco.Core.Models.Identity; namespace Umbraco.Core.Security { + //TODO: In v8 we need to change this to use an int? nullable TKey instead, see notes against overridden TwoFactorSignInAsync public class BackOfficeSignInManager : SignInManager { private readonly ILogger _logger; @@ -222,6 +223,9 @@ namespace Umbraco.Core.Security user.AccessFailedCount = 0; await UserManager.UpdateAsync(user); + //set the current request's principal to the identity just signed in! + _request.User = new ClaimsPrincipal(userIdentity); + _logger.WriteCore(TraceEventType.Information, 0, string.Format( "Login attempt succeeded for username {0} from IP address {1}", @@ -259,5 +263,67 @@ namespace Umbraco.Core.Security } return null; } + + /// + /// Two factor verification step + /// + /// + /// + /// + /// + /// + /// + /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it + /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that + /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate + /// all of this code to check for -1 instead. + /// + public override async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberBrowser) + { + var userId = await GetVerifiedUserIdAsync(); + if (userId == -1) + { + return SignInStatus.Failure; + } + var user = await UserManager.FindByIdAsync(userId); + if (user == null) + { + return SignInStatus.Failure; + } + if (await UserManager.IsLockedOutAsync(user.Id)) + { + return SignInStatus.LockedOut; + } + if (await UserManager.VerifyTwoFactorTokenAsync(user.Id, provider, code)) + { + // When token is verified correctly, clear the access failed count used for lockout + await UserManager.ResetAccessFailedCountAsync(user.Id); + await SignInAsync(user, isPersistent, rememberBrowser); + return SignInStatus.Success; + } + // If the token is incorrect, record the failure which also may cause the user to be locked out + await UserManager.AccessFailedAsync(user.Id); + return SignInStatus.Failure; + } + + /// Send a two factor code to a user + /// + /// + /// + /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it + /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that + /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate + /// all of this code to check for -1 instead. + /// + public override async Task SendTwoFactorCodeAsync(string provider) + { + var userId = await GetVerifiedUserIdAsync(); + if (userId == -1) + return false; + + var token = await UserManager.GenerateTwoFactorTokenAsync(userId, provider); + var identityResult = await UserManager.NotifyTwoFactorTokenAsync(userId, provider, token); + return identityResult.Succeeded; + } } } diff --git a/src/Umbraco.Core/Security/BackOfficeUserManager.cs b/src/Umbraco.Core/Security/BackOfficeUserManager.cs index 865f3f27cc..32f7d3bd8f 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserManager.cs @@ -157,7 +157,7 @@ namespace Umbraco.Core.Security #region What we support do not currently - //NOTE: Not sure if we really want/need to ever support this + //TODO: We could support this - but a user claims will mostly just be what is in the auth cookie public override bool SupportsUserClaim { get { return false; } @@ -259,6 +259,22 @@ namespace Umbraco.Core.Security //manager.SmsService = new SmsService(); } + /// + /// Used to validate a user's session + /// + /// + /// + /// + public virtual async Task ValidateSessionIdAsync(int userId, string sessionId) + { + var userSessionStore = Store as IUserSessionStore; + //if this is not set, for backwards compat (which would be super rare), we'll just approve it + if (userSessionStore == null) + return true; + + return await userSessionStore.ValidateSessionIdAsync(userId, sessionId); + } + /// /// This will determine which password hasher to use based on what is defined in config /// @@ -393,6 +409,33 @@ namespace Umbraco.Core.Security return await base.CheckPasswordAsync(user, password); } + public override Task ResetPasswordAsync(int userId, string token, string newPassword) + { + var result = base.ResetPasswordAsync(userId, token, newPassword); + if (result.Result.Succeeded) + RaisePasswordResetEvent(userId); + return result; + } + + /// + /// This is a special method that will reset the password but will raise the Password Changed event instead of the reset event + /// + /// + /// + /// + /// + /// + /// We use this because in the back office the only way an admin can change another user's password without first knowing their password + /// is to generate a token and reset it, however, when we do this we want to track a password change, not a password reset + /// + public Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) + { + var result = base.ResetPasswordAsync(userId, token, newPassword); + if (result.Result.Succeeded) + RaisePasswordChangedEvent(userId); + return result; + } + public override Task ChangePasswordAsync(int userId, string currentPassword, string newPassword) { var result = base.ChangePasswordAsync(userId, currentPassword, newPassword); @@ -527,27 +570,27 @@ namespace Umbraco.Core.Security internal void RaiseAccountLockedEvent(int userId) { - OnAccountLocked(new IdentityAuditEventArgs(AuditEvent.AccountLocked, GetCurrentRequestIpAddress(), userId)); + OnAccountLocked(new IdentityAuditEventArgs(AuditEvent.AccountLocked, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseAccountUnlockedEvent(int userId) { - OnAccountUnlocked(new IdentityAuditEventArgs(AuditEvent.AccountUnlocked, GetCurrentRequestIpAddress(), userId)); + OnAccountUnlocked(new IdentityAuditEventArgs(AuditEvent.AccountUnlocked, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseForgotPasswordRequestedEvent(int userId) { - OnForgotPasswordRequested(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordRequested, GetCurrentRequestIpAddress(), userId)); + OnForgotPasswordRequested(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordRequested, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseForgotPasswordChangedSuccessEvent(int userId) { - OnForgotPasswordChangedSuccess(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordChangedSuccess, GetCurrentRequestIpAddress(), userId)); + OnForgotPasswordChangedSuccess(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordChangedSuccess, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseLoginFailedEvent(int userId) { - OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, GetCurrentRequestIpAddress(), userId)); + OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseInvalidLoginAttemptEvent(string username) @@ -557,31 +600,33 @@ namespace Umbraco.Core.Security internal void RaiseLoginRequiresVerificationEvent(int userId) { - OnLoginRequiresVerification(new IdentityAuditEventArgs(AuditEvent.LoginRequiresVerification, GetCurrentRequestIpAddress(), userId)); + OnLoginRequiresVerification(new IdentityAuditEventArgs(AuditEvent.LoginRequiresVerification, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseLoginSuccessEvent(int userId) { - OnLoginSuccess(new IdentityAuditEventArgs(AuditEvent.LoginSucces, GetCurrentRequestIpAddress(), userId)); + OnLoginSuccess(new IdentityAuditEventArgs(AuditEvent.LoginSucces, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseLogoutSuccessEvent(int userId) { - OnLogoutSuccess(new IdentityAuditEventArgs(AuditEvent.LogoutSuccess, GetCurrentRequestIpAddress(), userId)); + OnLogoutSuccess(new IdentityAuditEventArgs(AuditEvent.LogoutSuccess, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaisePasswordChangedEvent(int userId) { - OnPasswordChanged(new IdentityAuditEventArgs(AuditEvent.PasswordChanged, GetCurrentRequestIpAddress(), userId)); + OnPasswordChanged(new IdentityAuditEventArgs(AuditEvent.PasswordChanged, GetCurrentRequestIpAddress(), affectedUser: userId)); } + //TODO: I don't think this is required anymore since from 7.7 we no longer display the reset password checkbox since that didn't make sense. internal void RaisePasswordResetEvent(int userId) { - OnPasswordReset(new IdentityAuditEventArgs(AuditEvent.PasswordReset, GetCurrentRequestIpAddress(), userId)); + OnPasswordReset(new IdentityAuditEventArgs(AuditEvent.PasswordReset, GetCurrentRequestIpAddress(), affectedUser: userId)); } + internal void RaiseResetAccessFailedCountEvent(int userId) { - OnResetAccessFailedCount(new IdentityAuditEventArgs(AuditEvent.ResetAccessFailedCount, GetCurrentRequestIpAddress(), userId)); + OnResetAccessFailedCount(new IdentityAuditEventArgs(AuditEvent.ResetAccessFailedCount, GetCurrentRequestIpAddress(), affectedUser: userId)); } public static event EventHandler AccountLocked; @@ -662,4 +707,5 @@ namespace Umbraco.Core.Security return httpContext.GetCurrentRequestIpAddress(); } } + } diff --git a/src/Umbraco.Core/Security/BackOfficeUserStore.cs b/src/Umbraco.Core/Security/BackOfficeUserStore.cs index e5500e689c..dc6a939c65 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserStore.cs @@ -18,7 +18,7 @@ using Task = System.Threading.Tasks.Task; namespace Umbraco.Core.Security { - public class BackOfficeUserStore : DisposableObject, + public class BackOfficeUserStore : DisposableObjectSlim, IUserStore, IUserPasswordStore, IUserEmailStore, @@ -26,12 +26,13 @@ namespace Umbraco.Core.Security IUserRoleStore, IUserSecurityStampStore, IUserLockoutStore, - IUserTwoFactorStore + IUserTwoFactorStore, + IUserSessionStore - //TODO: This would require additional columns/tables for now people will need to implement this on their own - //IUserPhoneNumberStore, - //TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation - //IQueryableUserStore + //TODO: This would require additional columns/tables for now people will need to implement this on their own + //IUserPhoneNumberStore, + //TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation + //IQueryableUserStore { private readonly IUserService _userService; private readonly IMemberTypeService _memberTypeService; @@ -59,7 +60,7 @@ namespace Umbraco.Core.Security } /// - /// Handles the disposal of resources. Derived from abstract class which handles common required locking logic. + /// Handles the disposal of resources. Derived from abstract class which handles common required locking logic. /// protected override void DisposeResources() { @@ -126,12 +127,15 @@ namespace Umbraco.Core.Security var found = _userService.GetUserById(asInt.Result); if (found != null) { + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. + var isLoginsPropertyDirty = user.IsPropertyDirty("Logins"); + if (UpdateMemberProperties(found, user)) { _userService.Save(found); } - if (user.IsPropertyDirty("Logins")) + if (isLoginsPropertyDirty) { var logins = await GetLoginsAsync(user); _externalLoginService.SaveUserLogins(found.Id, logins); @@ -752,5 +756,15 @@ namespace Umbraco.Core.Security if (_disposed) throw new ObjectDisposedException(GetType().Name); } + + public Task ValidateSessionIdAsync(int userId, string sessionId) + { + Guid guidSessionId; + if (Guid.TryParse(sessionId, out guidSessionId)) + { + return Task.FromResult(_userService.ValidateLoginSession(userId, guidSessionId)); + } + return Task.FromResult(false); + } } } diff --git a/src/Umbraco.Core/Security/IUserSessionStore.cs b/src/Umbraco.Core/Security/IUserSessionStore.cs new file mode 100644 index 0000000000..3454b19f84 --- /dev/null +++ b/src/Umbraco.Core/Security/IUserSessionStore.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; + +namespace Umbraco.Core.Security +{ + /// + /// An IUserStore interface part to implement if the store supports validating user session Ids + /// + /// + /// + public interface IUserSessionStore : IUserStore, IDisposable + where TUser : class, IUser + { + Task ValidateSessionIdAsync(int userId, string sessionId); + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/MembershipProviderBase.cs b/src/Umbraco.Core/Security/MembershipProviderBase.cs index 79eaa44afd..74170a4335 100644 --- a/src/Umbraco.Core/Security/MembershipProviderBase.cs +++ b/src/Umbraco.Core/Security/MembershipProviderBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Specialized; +using System.ComponentModel.DataAnnotations; using System.Configuration.Provider; using System.Security.Cryptography; using System.Text; @@ -9,6 +10,7 @@ using System.Web.Configuration; using System.Web.Hosting; using System.Web.Security; using Umbraco.Core.Composing; +using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Models; @@ -677,11 +679,7 @@ namespace Umbraco.Core.Security internal static bool IsEmailValid(string email) { - const string pattern = @"^(?!\.)(""([^""\r\\]|\\[""\r\\])*""|" - + @"([-a-z0-9!#$%&'*+/=?^_`{|}~]|(? + /// Nasty little hack to get httpcontextbase from an owin context + /// + /// + /// + internal static Attempt TryGetHttpContext(this IOwinContext owinContext) + { + var ctx = owinContext.Get(typeof(HttpContextBase).FullName); + return ctx == null ? Attempt.Fail() : Attempt.Succeed(ctx); + } + /// /// Gets the back office sign in manager out of OWIN /// diff --git a/src/Umbraco.Core/Security/SessionIdValidator.cs b/src/Umbraco.Core/Security/SessionIdValidator.cs new file mode 100644 index 0000000000..1737baa778 --- /dev/null +++ b/src/Umbraco.Core/Security/SessionIdValidator.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNet.Identity; +using Microsoft.AspNet.Identity.Owin; +using Microsoft.Owin; +using Microsoft.Owin.Infrastructure; +using Microsoft.Owin.Security.Cookies; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Identity; + +namespace Umbraco.Core.Security +{ + /// + /// Static helper class used to configure a CookieAuthenticationProvider to validate a cookie against a user's session id + /// + /// + /// This uses another cookie to track the last checked time which is done for a few reasons: + /// * We can't use the user's auth ticket to do thsi because we'd be re-issuing the auth ticket all of the time and it would never expire + /// plus the auth ticket size is much larger than this small value + /// * This will execute quite often (every minute per user) and in some cases there might be several requests that end up re-issuing the cookie so the cookie value should be small + /// * We want to avoid the user lookup if it's not required so that will only happen when the time diff is great enough in the cookie + /// + internal static class SessionIdValidator + { + public const string CookieName = "UMB_UCONTEXT_C"; + + public static async Task ValidateSessionAsync(TimeSpan validateInterval, CookieValidateIdentityContext context) + { + if (context.Request.Uri.IsBackOfficeRequest(HttpRuntime.AppDomainAppVirtualPath) == false) + return; + + var valid = await ValidateSessionAsync(validateInterval, context.OwinContext, context.Options.CookieManager, context.Options.SystemClock, context.Properties.IssuedUtc, context.Identity); + + if (valid == false) + { + context.RejectIdentity(); + context.OwinContext.Authentication.SignOut(context.Options.AuthenticationType); + } + } + + public static async Task ValidateSessionAsync( + TimeSpan validateInterval, + IOwinContext owinCtx, + ICookieManager cookieManager, + ISystemClock systemClock, + DateTimeOffset? authTicketIssueDate, + ClaimsIdentity currentIdentity) + { + if (owinCtx == null) throw new ArgumentNullException("owinCtx"); + if (cookieManager == null) throw new ArgumentNullException("cookieManager"); + if (systemClock == null) throw new ArgumentNullException("systemClock"); + + DateTimeOffset? issuedUtc = null; + var currentUtc = systemClock.UtcNow; + + //read the last checked time from a custom cookie + var lastCheckedCookie = cookieManager.GetRequestCookie(owinCtx, CookieName); + + if (lastCheckedCookie.IsNullOrWhiteSpace() == false) + { + DateTimeOffset parsed; + if (DateTimeOffset.TryParse(lastCheckedCookie, out parsed)) + { + issuedUtc = parsed; + } + } + + //no cookie, use the issue time of the auth ticket + if (issuedUtc.HasValue == false) + { + issuedUtc = authTicketIssueDate; + } + + // Only validate if enough time has elapsed + var validate = issuedUtc.HasValue == false; + if (issuedUtc.HasValue) + { + var timeElapsed = currentUtc.Subtract(issuedUtc.Value); + validate = timeElapsed > validateInterval; + } + + if (validate == false) + return true; + + var manager = owinCtx.GetUserManager(); + if (manager == null) + return false; + + var userId = currentIdentity.GetUserId(); + var user = await manager.FindByIdAsync(userId); + if (user == null) + return false; + + var sessionId = currentIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + if (await manager.ValidateSessionIdAsync(userId, sessionId) == false) + return false; + + //we will re-issue the cookie last checked cookie + cookieManager.AppendResponseCookie( + owinCtx, + CookieName, + DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"), + new CookieOptions + { + HttpOnly = true, + Secure = GlobalSettings.UseSSL || owinCtx.Request.IsSecure, + Path = "/" + }); + + return true; + } + + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs index f9f3da77b9..605c8b4e9d 100644 --- a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs +++ b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs @@ -36,6 +36,7 @@ namespace Umbraco.Core.Security var username = identity.GetUserName(); var session = identity.FindFirstValue(Constants.Security.SessionIdClaimType); + var securityStamp = identity.FindFirstValue(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType); var startContentId = identity.FindFirstValue(Constants.Security.StartContentNodeIdClaimType); var startMediaId = identity.FindFirstValue(Constants.Security.StartMediaNodeIdClaimType); @@ -66,8 +67,9 @@ namespace Umbraco.Core.Security var roles = identity.FindAll(x => x.Type == DefaultRoleClaimType).Select(role => role.Value).ToList(); var allowedApps = identity.FindAll(x => x.Type == Constants.Security.AllowedApplicationsClaimType).Select(app => app.Value).ToList(); - var userData = new UserData(session) + var userData = new UserData { + SecurityStamp = securityStamp, SessionId = session, AllowedApplications = allowedApps.ToArray(), Culture = culture, @@ -189,7 +191,8 @@ namespace Umbraco.Core.Security Constants.Security.StartContentNodeIdClaimType, Constants.Security.StartMediaNodeIdClaimType, ClaimTypes.Locality, - Constants.Security.SessionIdClaimType + Constants.Security.SessionIdClaimType, + Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType }; } } @@ -219,16 +222,12 @@ namespace Umbraco.Core.Security AddClaim(new Claim(ClaimTypes.Locality, Culture, ClaimValueTypes.String, Issuer, Issuer, this)); if (HasClaim(x => x.Type == Constants.Security.SessionIdClaimType) == false && SessionId.IsNullOrWhiteSpace() == false) - { AddClaim(new Claim(Constants.Security.SessionIdClaimType, SessionId, ClaimValueTypes.String, Issuer, Issuer, this)); - //The security stamp claim is also required... this is because this claim type is hard coded - // by the SecurityStampValidator, see: https://katanaproject.codeplex.com/workitem/444 - if (HasClaim(x => x.Type == Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType) == false) - { - AddClaim(new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, SessionId, ClaimValueTypes.String, Issuer, Issuer, this)); - } - } + //The security stamp claim is also required... this is because this claim type is hard coded + // by the SecurityStampValidator, see: https://katanaproject.codeplex.com/workitem/444 + if (HasClaim(x => x.Type == Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType) == false) + AddClaim(new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, SecurityStamp, ClaimValueTypes.String, Issuer, Issuer, this)); //Add each app as a separate claim if (HasClaim(x => x.Type == Constants.Security.AllowedApplicationsClaimType) == false) @@ -307,6 +306,11 @@ namespace Umbraco.Core.Security get { return UserData.SessionId; } } + public string SecurityStamp + { + get { return UserData.SecurityStamp; } + } + public string[] Roles { get { return UserData.Roles; } diff --git a/src/Umbraco.Core/Security/UserData.cs b/src/Umbraco.Core/Security/UserData.cs index 9f80343ace..7e510ba708 100644 --- a/src/Umbraco.Core/Security/UserData.cs +++ b/src/Umbraco.Core/Security/UserData.cs @@ -20,7 +20,7 @@ namespace Umbraco.Core.Security /// Use this constructor to create/assign new UserData to the ticket /// /// - /// The security stamp for the user + /// The current sessionId for the user /// public UserData(string sessionId) { @@ -30,11 +30,17 @@ namespace Umbraco.Core.Security } /// - /// This is the 'security stamp' for validation + /// Gets or sets the session identifier. /// [DataMember(Name = "sessionId")] public string SessionId { get; set; } + /// + /// Gets or sets the security stamp. + /// + [DataMember(Name = "securityStamp")] + public string SecurityStamp { get; set; } + [DataMember(Name = "id")] public object Id { get; set; } diff --git a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs index 8980c13ec4..1e40f46775 100644 --- a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs +++ b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs @@ -100,7 +100,7 @@ namespace Umbraco.Core.Sync return null; } - private static string GetApplicationUrlFromCurrentRequest(HttpRequestBase request) + public static string GetApplicationUrlFromCurrentRequest(HttpRequestBase request) { // if (HTTP and SSL not required) or (HTTPS and SSL required), // use ports from request diff --git a/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs b/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs index d1a3e11b15..cf545d0687 100644 --- a/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs +++ b/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs @@ -15,6 +15,7 @@ using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Configuration; using Umbraco.Core.Scoping; namespace Umbraco.Core.Sync @@ -34,6 +35,7 @@ namespace Umbraco.Core.Sync private readonly object _locko = new object(); private readonly ProfilingLogger _profilingLogger; private readonly ISqlContext _sqlContext; + private readonly Lazy _distCacheFilePath = new Lazy(GetDistCacheFilePath); private int _lastId = -1; private DateTime _lastSync; private DateTime _lastPruned; @@ -63,6 +65,8 @@ namespace Umbraco.Core.Sync protected IScopeProvider ScopeProvider { get; } protected Sql Sql() => _sqlContext.Sql(); + + private string DistCacheFilePath => _distCacheFilePath.Value; #region Messenger @@ -92,7 +96,8 @@ namespace Umbraco.Core.Sync { UtcStamp = DateTime.UtcNow, Instructions = JsonConvert.SerializeObject(instructions, Formatting.None), - OriginIdentity = LocalIdentity + OriginIdentity = LocalIdentity, + InstructionCount = instructions.Sum(x => x.JsonIdCount) }; using (var scope = ScopeProvider.CreateScope()) @@ -182,10 +187,9 @@ namespace Umbraco.Core.Sync } else { - //check for how many instructions there are to process - //TODO: In 7.6 we need to store the count of instructions per row since this is not affective because there can be far more than one (if not thousands) - // of instructions in a single row. - var count = database.ExecuteScalar("SELECT COUNT(*) FROM umbracoCacheInstruction WHERE id > @lastId", new {lastId = _lastId}); + //check for how many instructions there are to process, each row contains a count of the number of instructions contained in each + //row so we will sum these numbers to get the actual count. + var count = database.ExecuteScalar("SELECT SUM(instructionCount) FROM umbracoCacheInstruction WHERE id > @lastId", new {lastId = _lastId}); if (count > Options.MaxProcessingInstructionCount) { //too many instructions, proceed to cold boot @@ -222,7 +226,7 @@ namespace Umbraco.Core.Sync /// /// Synchronize the server (throttled). /// - protected void Sync() + protected internal void Sync() { lock (_locko) { @@ -484,11 +488,10 @@ namespace Umbraco.Core.Sync /// private void ReadLastSynced() { - var path = SyncFilePath; - if (File.Exists(path) == false) return; + if (File.Exists(DistCacheFilePath) == false) return; - var content = File.ReadAllText(path); - if (int.TryParse(content, out int last)) + var content = File.ReadAllText(DistCacheFilePath); + if (int.TryParse(content, out var last)) _lastId = last; } @@ -501,7 +504,7 @@ namespace Umbraco.Core.Sync /// private void SaveLastSynced(int id) { - File.WriteAllText(SyncFilePath, id.ToString(CultureInfo.InvariantCulture)); + File.WriteAllText(DistCacheFilePath, id.ToString(CultureInfo.InvariantCulture)); _lastId = id; } @@ -521,20 +524,40 @@ namespace Umbraco.Core.Sync + "/D" + AppDomain.CurrentDomain.Id // eg 22 + "] " + Guid.NewGuid().ToString("N").ToUpper(); // make it truly unique - /// - /// Gets the sync file path for the local server. - /// - /// The sync file path for the local server. - private static string SyncFilePath + private static string GetDistCacheFilePath() { - get - { - var tempFolder = IOHelper.MapPath("~/App_Data/TEMP/DistCache/" + NetworkHelper.FileSafeMachineName); - if (Directory.Exists(tempFolder) == false) - Directory.CreateDirectory(tempFolder); + var fileName = HttpRuntime.AppDomainAppId.ReplaceNonAlphanumericChars(string.Empty) + "-lastsynced.txt"; - return Path.Combine(tempFolder, HttpRuntime.AppDomainAppId.ReplaceNonAlphanumericChars(string.Empty) + "-lastsynced.txt"); + string distCacheFilePath; + switch (GlobalSettings.LocalTempStorageLocation) + { + case LocalTempStorage.AspNetTemp: + distCacheFilePath = Path.Combine(HttpRuntime.CodegenDir, @"UmbracoData", fileName); + break; + case LocalTempStorage.EnvironmentTemp: + var appDomainHash = HttpRuntime.AppDomainAppId.ToSHA1(); + var cachePath = Path.Combine(Environment.ExpandEnvironmentVariables("%temp%"), "UmbracoData", + //include the appdomain hash is just a safety check, for example if a website is moved from worker A to worker B and then back + // to worker A again, in theory the %temp% folder should already be empty but we really want to make sure that its not + // utilizing an old path + appDomainHash); + distCacheFilePath = Path.Combine(cachePath, fileName); + break; + case LocalTempStorage.Default: + default: + var tempFolder = IOHelper.MapPath("~/App_Data/TEMP/DistCache"); + distCacheFilePath = Path.Combine(tempFolder, fileName); + break; } + + //ensure the folder exists + var folder = Path.GetDirectoryName(distCacheFilePath); + if (folder == null) + throw new InvalidOperationException("The folder could not be determined for the file " + distCacheFilePath); + if (Directory.Exists(folder) == false) + Directory.CreateDirectory(folder); + + return distCacheFilePath; } #endregion diff --git a/src/Umbraco.Core/Sync/RefreshInstruction.cs b/src/Umbraco.Core/Sync/RefreshInstruction.cs index b6caf42db6..3038f95e65 100644 --- a/src/Umbraco.Core/Sync/RefreshInstruction.cs +++ b/src/Umbraco.Core/Sync/RefreshInstruction.cs @@ -17,7 +17,10 @@ namespace Umbraco.Core.Sync // need this public, parameter-less constructor so the web service messenger // can de-serialize the instructions it receives public RefreshInstruction() - { } + { + //set default - this value is not used for reading after it's been deserialized, it's only used for persisting the instruction to the db + JsonIdCount = 1; + } // need this public one so it can be de-serialized - used by the Json thing // otherwise, should use GetInstructions(...) @@ -29,12 +32,16 @@ namespace Umbraco.Core.Sync IntId = intId; JsonIds = jsonIds; JsonPayload = jsonPayload; + //set default - this value is not used for reading after it's been deserialized, it's only used for persisting the instruction to the db + JsonIdCount = 1; } private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType) { RefresherId = refresher.RefresherUniqueId; RefreshType = refreshType; + //set default - this value is not used for reading after it's been deserialized, it's only used for persisting the instruction to the db + JsonIdCount = 1; } private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType, Guid guidId) @@ -49,9 +56,21 @@ namespace Umbraco.Core.Sync IntId = intId; } - private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType, string json) + /// + /// A private constructor to create a new instance + /// + /// + /// + /// + /// + /// When the refresh method is we know how many Ids are being refreshed so we know the instruction + /// count which will be taken into account when we store this count in the database. + /// + private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType, string json, int idCount = 1) : this(refresher, refreshType) { + JsonIdCount = idCount; + if (refreshType == RefreshMethodType.RefreshByJson) JsonPayload = json; else @@ -76,8 +95,12 @@ namespace Umbraco.Core.Sync case MessageType.RefreshById: if (idType == null) throw new InvalidOperationException("Cannot refresh by id if idType is null."); - if (idType == typeof (int)) // bulk of ints is supported - return new[] { new RefreshInstruction(refresher, RefreshMethodType.RefreshByIds, JsonConvert.SerializeObject(ids.Cast().ToArray())) }; + if (idType == typeof(int)) + { + // bulk of ints is supported + var intIds = ids.Cast().ToArray(); + return new[] { new RefreshInstruction(refresher, RefreshMethodType.RefreshByIds, JsonConvert.SerializeObject(intIds), intIds.Length) }; + } // else must be guids, bulk of guids is not supported, iterate return ids.Select(x => new RefreshInstruction(refresher, RefreshMethodType.RefreshByGuid, (Guid) x)); @@ -120,6 +143,14 @@ namespace Umbraco.Core.Sync /// public string JsonIds { get; set; } + /// + /// Gets or sets the number of Ids contained in the JsonIds json value + /// + /// + /// This is used to determine the instruction count per row + /// + public int JsonIdCount { get; set; } + /// /// Gets or sets the payload data value. /// diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 434f25638e..7ffdaa909c 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -98,6 +98,7 @@ + @@ -140,10 +141,12 @@ + + @@ -317,7 +320,11 @@ + + + + @@ -335,9 +342,14 @@ + + + + + @@ -1276,6 +1288,7 @@ + @@ -1283,6 +1296,7 @@ + From 8bfb8e2b727bce2c8da75a1a593c958930a73de3 Mon Sep 17 00:00:00 2001 From: Stephan Date: Wed, 21 Mar 2018 14:40:59 +0100 Subject: [PATCH 05/15] Port v7@2aa0dfb2c5 - WIP --- .../Migrations/Install/DatabaseDataCreator.cs | 4 +- .../Install/DatabaseSchemaCreator.cs | 131 ++++++++---------- .../Install/DatabaseSchemaResult.cs | 12 ++ .../Migrations/MigrationBase_Extra.cs | 8 ++ .../Migrations/Upgrade/UmbracoPlan.cs | 84 ++++++----- .../AddIndexToPropertyTypeAliasColumn.cs | 27 ++++ .../V_7_8_0/AddInstructionCountColumn.cs | 20 +++ .../Upgrade/V_7_8_0/AddMediaVersionTable.cs | 65 +++++++++ .../Upgrade/V_7_8_0/AddTourDataUserColumn.cs | 21 +++ .../Upgrade/V_7_8_0/AddUserLoginTable.cs | 22 +++ .../V_7_9_0/AddIsSensitiveMemberTypeColumn.cs | 20 +++ .../Upgrade/V_7_9_0/AddUmbracoAuditTable.cs | 22 +++ .../Upgrade/V_7_9_0/AddUmbracoConsentTable.cs | 22 +++ .../V_7_9_0/CreateSensitiveDataUserGroup.cs | 27 ++++ .../Upgrade/V_8_0_0/DataTypeMigration.cs | 2 +- .../Upgrade/V_8_0_0/VariantsMigration.cs | 2 +- src/Umbraco.Core/Models/AuditEntry.cs | 94 +++++++++++++ src/Umbraco.Core/Models/AuditItem.cs | 19 ++- src/Umbraco.Core/Models/Consent.cs | 102 ++++++++++++++ src/Umbraco.Core/Models/ConsentExtensions.cs | 18 +++ src/Umbraco.Core/Models/ConsentState.cs | 38 +++++ src/Umbraco.Core/Models/ContentBase.cs | 43 ++++-- src/Umbraco.Core/Models/ContentType.cs | 8 +- src/Umbraco.Core/Models/IAuditEntry.cs | 60 ++++++++ src/Umbraco.Core/Models/IAuditItem.cs | 12 ++ src/Umbraco.Core/Models/IConsent.cs | 55 ++++++++ src/Umbraco.Core/Models/IMemberType.cs | 14 ++ src/Umbraco.Core/Models/Member.cs | 14 +- src/Umbraco.Core/Models/MemberType.cs | 45 ++++-- .../Models/MemberTypePropertyProfileAccess.cs | 4 +- src/Umbraco.Core/Models/UmbracoObjectTypes.cs | 4 +- src/Umbraco.Core/Models/UserExtensions.cs | 13 +- .../Persistence/Constants-DatabaseSchema.cs | 1 + src/Umbraco.Core/Persistence/Dtos/MediaDto.cs | 21 +++ .../Persistence/Dtos/MediaVersionDto.cs | 25 ++++ .../Factories/AuditEntryFactory.cs | 52 +++++++ .../Persistence/Factories/ConsentFactory.cs | 65 +++++++++ .../Factories/ContentBaseFactory.cs | 48 ++++++- .../Factories/ContentTypeFactory.cs | 3 +- .../Factories/MemberTypeReadOnlyFactory.cs | 10 +- .../Persistence/Factories/UserFactory.cs | 4 +- .../Persistence/Mappers/AuditEntryMapper.cs | 40 ++++++ .../Persistence/Mappers/AuditMapper.cs | 32 +++++ .../Persistence/Mappers/ConsentMapper.cs | 39 ++++++ src/Umbraco.Core/Umbraco.Core.csproj | 23 +++ .../Migrations/AdvancedMigrationTests.cs | 4 +- 46 files changed, 1240 insertions(+), 159 deletions(-) create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddIndexToPropertyTypeAliasColumn.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddInstructionCountColumn.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddMediaVersionTable.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddTourDataUserColumn.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddUserLoginTable.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddIsSensitiveMemberTypeColumn.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoAuditTable.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoConsentTable.cs create mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/CreateSensitiveDataUserGroup.cs create mode 100644 src/Umbraco.Core/Models/AuditEntry.cs create mode 100644 src/Umbraco.Core/Models/Consent.cs create mode 100644 src/Umbraco.Core/Models/ConsentExtensions.cs create mode 100644 src/Umbraco.Core/Models/ConsentState.cs create mode 100644 src/Umbraco.Core/Models/IAuditEntry.cs create mode 100644 src/Umbraco.Core/Models/IAuditItem.cs create mode 100644 src/Umbraco.Core/Models/IConsent.cs create mode 100644 src/Umbraco.Core/Persistence/Dtos/MediaDto.cs create mode 100644 src/Umbraco.Core/Persistence/Dtos/MediaVersionDto.cs create mode 100644 src/Umbraco.Core/Persistence/Factories/AuditEntryFactory.cs create mode 100644 src/Umbraco.Core/Persistence/Factories/ConsentFactory.cs create mode 100644 src/Umbraco.Core/Persistence/Mappers/AuditEntryMapper.cs create mode 100644 src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs create mode 100644 src/Umbraco.Core/Persistence/Mappers/ConsentMapper.cs diff --git a/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs index 1371486ce5..f12533a5de 100644 --- a/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs @@ -153,11 +153,13 @@ namespace Umbraco.Core.Migrations.Install _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 2, StartMediaId = -1, StartContentId = -1, Alias = "writer", Name = "Writers", DefaultPermissions = "CAH:F", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-edit" }); _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 3, StartMediaId = -1, StartContentId = -1, Alias = "editor", Name = "Editors", DefaultPermissions = "CADMOSKTPUZ:5Fï", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-tools" }); _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 4, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.TranslatorGroupAlias, Name = "Translators", DefaultPermissions = "AF", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-globe" }); + _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 5, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.SensitiveDataGroupAlias, Name = "Sensitive data", DefaultPermissions = "", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-lock" }); } private void CreateUser2UserGroupData() { - _database.Insert(new User2UserGroupDto { UserGroupId = 1, UserId = Constants.Security.SuperId }); + _database.Insert(new User2UserGroupDto { UserGroupId = 1, UserId = Constants.Security.SuperId }); // add super to admins + _database.Insert(new User2UserGroupDto { UserGroupId = 5, UserId = Constants.Security.SuperId }); // add super to sensitive data } private void CreateUserGroup2AppData() diff --git a/src/Umbraco.Core/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Core/Migrations/Install/DatabaseSchemaCreator.cs index dfd173681b..cc9c0f3893 100644 --- a/src/Umbraco.Core/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Core/Migrations/Install/DatabaseSchemaCreator.cs @@ -28,71 +28,60 @@ namespace Umbraco.Core.Migrations.Install private ISqlSyntaxProvider SqlSyntax => _database.SqlContext.SqlSyntax; // all tables, in order - public static readonly Dictionary OrderedTables = new Dictionary + public static readonly List OrderedTables = new List { - {0, typeof (NodeDto)}, - {1, typeof (ContentTypeDto)}, - {2, typeof (TemplateDto)}, - {3, typeof (ContentDto)}, - {4, typeof (ContentVersionDto)}, - {5, typeof (DocumentDto)}, - {6, typeof (ContentTypeTemplateDto)}, - {7, typeof (DataTypeDto)}, - //removed: {8, typeof (DataTypePreValueDto)}, - {9, typeof (DictionaryDto)}, - - {10, typeof (LanguageDto)}, - {11, typeof (LanguageTextDto)}, - {12, typeof (DomainDto)}, - {13, typeof (LogDto)}, - {14, typeof (MacroDto)}, - {15, typeof (MacroPropertyDto)}, - {16, typeof (MemberTypeDto)}, - {17, typeof (MemberDto)}, - {18, typeof (Member2MemberGroupDto)}, - {19, typeof (ContentXmlDto)}, - - {20, typeof (PreviewXmlDto)}, - {21, typeof (PropertyTypeGroupDto)}, - {22, typeof (PropertyTypeDto)}, - {23, typeof (PropertyDataDto)}, - {24, typeof (RelationTypeDto)}, - {25, typeof (RelationDto)}, - //removed: {26... - //removed: {27... - {28, typeof (TagDto)}, - {29, typeof (TagRelationshipDto)}, - - //removed: {30... - //removed in 7.6: {31, typeof (UserTypeDto)}, - {32, typeof (UserDto)}, - {33, typeof (TaskTypeDto)}, - {34, typeof (TaskDto)}, - {35, typeof (ContentType2ContentTypeDto)}, - {36, typeof (ContentTypeAllowedContentTypeDto)}, - //removed in 7.6: {37, typeof (User2AppDto)}, - {38, typeof (User2NodeNotifyDto)}, - //removed in 7.6: {39, typeof (User2NodePermissionDto)}, - - {40, typeof (ServerRegistrationDto)}, - {41, typeof (AccessDto)}, - {42, typeof (AccessRuleDto)}, - {43, typeof (CacheInstructionDto)}, - {44, typeof (ExternalLoginDto)}, - //removed: {45, typeof (MigrationDto)}, - //removed: {46, typeof (UmbracoDeployChecksumDto)}, - //removed: {47, typeof (UmbracoDeployDependencyDto)}, - {48, typeof (RedirectUrlDto) }, - {49, typeof (LockDto) }, - - {50, typeof (UserGroupDto) }, - {51, typeof (User2UserGroupDto) }, - {52, typeof (UserGroup2NodePermissionDto) }, - {53, typeof (UserGroup2AppDto) }, - {54, typeof (UserStartNodeDto) }, - {55, typeof (ContentNuDto) }, - {56, typeof (DocumentVersionDto) }, - {57, typeof (KeyValueDto) } + typeof (NodeDto), + typeof (ContentTypeDto), + typeof (TemplateDto), + typeof (ContentDto), + typeof (ContentVersionDto), + typeof (MediaVersionDto), + typeof (DocumentDto), + typeof (ContentTypeTemplateDto), + typeof (DataTypeDto), + typeof (DictionaryDto), + typeof (LanguageDto), + typeof (LanguageTextDto), + typeof (DomainDto), + typeof (LogDto), + typeof (MacroDto), + typeof (MacroPropertyDto), + typeof (MemberTypeDto), + typeof (MemberDto), + typeof (Member2MemberGroupDto), + typeof (ContentXmlDto), + typeof (PreviewXmlDto), + typeof (PropertyTypeGroupDto), + typeof (PropertyTypeDto), + typeof (PropertyDataDto), + typeof (RelationTypeDto), + typeof (RelationDto), + typeof (TagDto), + typeof (TagRelationshipDto), + typeof (UserDto), + typeof (TaskTypeDto), + typeof (TaskDto), + typeof (ContentType2ContentTypeDto), + typeof (ContentTypeAllowedContentTypeDto), + typeof (User2NodeNotifyDto), + typeof (ServerRegistrationDto), + typeof (AccessDto), + typeof (AccessRuleDto), + typeof (CacheInstructionDto), + typeof (ExternalLoginDto), + typeof (RedirectUrlDto), + typeof (LockDto), + typeof (UserGroupDto), + typeof (User2UserGroupDto), + typeof (UserGroup2NodePermissionDto), + typeof (UserGroup2AppDto), + typeof (UserStartNodeDto), + typeof (ContentNuDto), + typeof (DocumentVersionDto), + typeof (KeyValueDto), + typeof (UserLoginDto), + typeof (ConsentDto), + typeof (AuditEntryDto) }; /// @@ -102,11 +91,10 @@ namespace Umbraco.Core.Migrations.Install { _logger.Info("Start UninstallDatabaseSchema"); - foreach (var item in OrderedTables.OrderByDescending(x => x.Key)) + foreach (var table in OrderedTables.AsEnumerable().Reverse()) { - var tableNameAttribute = item.Value.FirstAttribute(); - - var tableName = tableNameAttribute == null ? item.Value.Name : tableNameAttribute.Value; + var tableNameAttribute = table.FirstAttribute(); + var tableName = tableNameAttribute == null ? table.Name : tableNameAttribute.Value; _logger.Info("Uninstall" + tableName); @@ -135,8 +123,8 @@ namespace Umbraco.Core.Migrations.Install if (e.Cancel == false) { var dataCreation = new DatabaseDataCreator(_database, _logger); - foreach (var item in OrderedTables.OrderBy(x => x.Key)) - CreateTable(false, item.Value, dataCreation); + foreach (var table in OrderedTables) + CreateTable(false, table, dataCreation); } FireAfterCreation(e); @@ -160,8 +148,7 @@ namespace Umbraco.Core.Migrations.Install }).ToArray(); result.TableDefinitions.AddRange(OrderedTables - .OrderBy(x => x.Key) - .Select(x => DefinitionFactory.GetTableDefinition(x.Value, SqlSyntax))); + .Select(x => DefinitionFactory.GetTableDefinition(x, SqlSyntax))); ValidateDbTables(result); ValidateDbColumns(result); diff --git a/src/Umbraco.Core/Migrations/Install/DatabaseSchemaResult.cs b/src/Umbraco.Core/Migrations/Install/DatabaseSchemaResult.cs index 41184c4471..0ec27cf0b1 100644 --- a/src/Umbraco.Core/Migrations/Install/DatabaseSchemaResult.cs +++ b/src/Umbraco.Core/Migrations/Install/DatabaseSchemaResult.cs @@ -136,6 +136,18 @@ namespace Umbraco.Core.Migrations.Install return new Version(7, 6, 0); } + //if the error is for cmsMedia it must be the previous version to 7.8 since that is when it is added + if (Errors.Any(x => x.Item1.Equals("Table") && (x.Item2.InvariantEquals("umbracoMedia")))) + { + return new Version(7, 7, 0); + } + + //if the error is for isSensitive column it must be the previous version to 7.9 since that is when it is added + if (Errors.Any(x => x.Item1.Equals("Column") && (x.Item2.InvariantEquals("cmsMemberType,isSensitive")))) + { + return new Version(7, 8, 0); + } + return UmbracoVersion.Current; } diff --git a/src/Umbraco.Core/Migrations/MigrationBase_Extra.cs b/src/Umbraco.Core/Migrations/MigrationBase_Extra.cs index 91342de867..0f871abb5d 100644 --- a/src/Umbraco.Core/Migrations/MigrationBase_Extra.cs +++ b/src/Umbraco.Core/Migrations/MigrationBase_Extra.cs @@ -11,6 +11,14 @@ namespace Umbraco.Core.Migrations { // provides extra methods for migrations + protected void AddColumn(string columnName) + { + var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); + var column = table.Columns.First(x => x.Name == columnName); + var createSql = SqlSyntax.Format(column); + Database.Execute(string.Format(SqlSyntax.AddColumn, SqlSyntax.GetQuotedTableName(table.Name), createSql)); + } + protected void AddColumn(string tableName, string columnName) { //if (ColumnExists(tableName, columnName)) diff --git a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs index 16ec3d0a30..3f676c78bf 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs @@ -7,6 +7,8 @@ using Umbraco.Core.Migrations.Upgrade.V_7_5_0; using Umbraco.Core.Migrations.Upgrade.V_7_5_5; using Umbraco.Core.Migrations.Upgrade.V_7_6_0; using Umbraco.Core.Migrations.Upgrade.V_7_7_0; +using Umbraco.Core.Migrations.Upgrade.V_7_8_0; +using Umbraco.Core.Migrations.Upgrade.V_7_9_0; using Umbraco.Core.Migrations.Upgrade.V_8_0_0; namespace Umbraco.Core.Migrations.Upgrade @@ -36,8 +38,8 @@ namespace Umbraco.Core.Migrations.Upgrade if (!SemVersion.TryParse(ConfigurationManager.AppSettings["umbracoConfigurationStatus"], out var currentVersion)) throw new InvalidOperationException("Could not get current version from web.config umbracoConfigurationStatus appSetting."); - // must be at least 7.8.0 - fixme adjust when releasing - if (currentVersion < new SemVersion(7, 8)) + // must be at least 7.?.? - fixme adjust when releasing + if (currentVersion < new SemVersion(7, 999)) throw new InvalidOperationException($"Version {currentVersion} cannot be upgraded to {UmbracoVersion.SemanticVersion}."); // cannot go back in time @@ -60,12 +62,6 @@ namespace Umbraco.Core.Migrations.Upgrade private void DefinePlan() { - // INSTALL - // - // when installing, the source state is empty, and the target state should be the final state. - - Add(string.Empty, "{CA7DB949-3EF4-403D-8464-F9BA36A52E87}"); - // UPGRADE FROM 7 // // when 8.0.0 is released, on the first upgrade, the state is automatically @@ -74,45 +70,38 @@ namespace Umbraco.Core.Migrations.Upgrade // then, as more v7 and v8 versions are released, new chains needs to be defined to // support the upgrades (new v7 may backport some migrations and require their own // upgrade paths, etc). + // fixme adjust when releasing - From("{init-7.8.0}") - .Chain("{7C447271-CA3F-4A6A-A913-5D77015655CB}") // add more lock objects - .Chain("{CBFF58A2-7B50-4F75-8E98-249920DB0F37}") - .Chain("{3D18920C-E84D-405C-A06A-B7CEE52FE5DD}") - .Chain("{FB0A5429-587E-4BD0-8A67-20F0E7E62FF7}") - .Chain("{F0C42457-6A3B-4912-A7EA-F27ED85A2092}") - .Chain("{8640C9E4-A1C0-4C59-99BB-609B4E604981}") - .Chain("{DD1B99AF-8106-4E00-BAC7-A43003EA07F8}") - .Chain("{9DF05B77-11D1-475C-A00A-B656AF7E0908}") - .Chain("{CA7DB949-3EF4-403D-8464-F9BA36A52E87}");; + From("{init-7.8.0}"); + Chain("{7C447271-CA3F-4A6A-A913-5D77015655CB}"); // add more lock objects + Chain("{CBFF58A2-7B50-4F75-8E98-249920DB0F37}"); + Chain("{3D18920C-E84D-405C-A06A-B7CEE52FE5DD}"); + Chain("{FB0A5429-587E-4BD0-8A67-20F0E7E62FF7}"); + Chain("{F0C42457-6A3B-4912-A7EA-F27ED85A2092}"); + Chain("{8640C9E4-A1C0-4C59-99BB-609B4E604981}"); + Chain("{DD1B99AF-8106-4E00-BAC7-A43003EA07F8}"); + Chain("{9DF05B77-11D1-475C-A00A-B656AF7E0908}"); + Chain("{CA7DB949-3EF4-403D-8464-F9BA36A52E87}"); // 7.8.1 = same as 7.8.0 - From("{init-7.8.1}") - .Chain("{init-7.8.0}"); + From("{init-7.8.1}"); + Chain("{init-7.8.0}"); // 7.9.0 = requires its own chain - From("{init-7.9.0}") - // chain... - .Chain("{82C4BA1D-7720-46B1-BBD7-07F3F73800E6}"); + From("{init-7.9.0}"); + // chain... + Chain("{82C4BA1D-7720-46B1-BBD7-07F3F73800E6}"); + // UPGRADE 8 // // starting from the original 8.0.0 final state, chain migrations to upgrade version 8, // defining new final states as more migrations are added to the chain. - - //From("") - // .Chain("") - // .Chain(""); - - // WIP 8 // // before v8 is released, some sites may exist, and these "pre-8" versions require their - // own upgrade plan. in other words, this is the plan for sites that were on v8 before + // own upgrade plan. in other words, this is also the plan for sites that were on v8 before // v8 was released - // fixme - this is essentially for ZpqrtBnk website - // need to determine which version it is and where it should resume running migrations - // 8.0.0 From("{init-origin}"); Chain("{98347B5E-65BF-4DD7-BB43-A09CB7AF4FCA}"); @@ -129,8 +118,6 @@ namespace Umbraco.Core.Migrations.Upgrade Chain("{44484C32-EEB3-4A12-B1CB-11E02CE22AB2}"); // 7.6.0 - //Chain("{858B4039-070C-4928-BBEC-DDE8303352DA}"); - //Chain("{64F587C1-0B28-4D78-B4CC-26B7D87F69C1}"); Chain("{3586E4E9-2922-49EB-8E2A-A530CE6DBDE0}"); Chain("{D4A5674F-654D-4CC7-85E5-CFDBC533A318}"); Chain("{7F828EDD-6622-4A8D-AD80-EEAF46C11680}"); @@ -156,6 +143,33 @@ namespace Umbraco.Core.Migrations.Upgrade Chain("{139F26D7-7E08-48E3-81D9-E50A21A72F67}"); Chain("{CC1B1201-1328-443C-954A-E0BBB8CCC1B5}"); Chain("{CA7DB949-3EF4-403D-8464-F9BA36A52E87}"); + + // at this point of the chain, people started to work on v8, so whenever we + // merge stuff from v7, we have to chain the migrations here so they also + // run for v8. + + // mergin from 7.8.0 + Chain("{FDCB727A-EFB6-49F3-89E4-A346503AB849}"); + Chain("{2A796A08-4FE4-4783-A1A5-B8A6C8AA4A92}"); + Chain("{1A46A98B-2AAB-4C8E-870F-A2D55A97FD1F}"); + Chain("{0AE053F6-2683-4234-87B2-E963F8CE9498}"); + Chain("{D454541C-15C5-41CF-8109-937F26A78E71}"); + + // merging from 7.9.0 + Chain("{89A728D1-FF4C-4155-A269-62CC09AD2131}"); + Chain("{FD8631BC-0388-425C-A451-5F58574F6F05}"); + Chain("{2821F53E-C58B-4812-B184-9CD240F990D7}"); + Chain("{8918450B-3DA0-4BB7-886A-6FA8B7E4186E}"); // final state + + // BEWARE! whenever changing the final state, update below! + + + // INSTALL + // + // when installing, the source state is empty, and the target state should be the final state. + // BEWARE! this MUST match the final state above! + + Add(string.Empty, "{8918450B-3DA0-4BB7-886A-6FA8B7E4186E}"); } } } diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddIndexToPropertyTypeAliasColumn.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddIndexToPropertyTypeAliasColumn.cs new file mode 100644 index 0000000000..ddb084a609 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddIndexToPropertyTypeAliasColumn.cs @@ -0,0 +1,27 @@ +using System.Linq; +using Umbraco.Core.Persistence.SqlSyntax; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_8_0 +{ + internal class AddIndexToPropertyTypeAliasColumn : MigrationBase + { + public AddIndexToPropertyTypeAliasColumn(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var dbIndexes = SqlSyntax.GetDefinedIndexesDefinitions(Context.Database); + + //make sure it doesn't already exist + if (dbIndexes.Any(x => x.IndexName.InvariantEquals("IX_cmsPropertyTypeAlias")) == false) + { + //we can apply the index + Create.Index("IX_cmsPropertyTypeAlias").OnTable(Constants.DatabaseSchema.Tables.PropertyType) + .OnColumn("Alias") + .Ascending().WithOptions().NonClustered() + .Do(); + } + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddInstructionCountColumn.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddInstructionCountColumn.cs new file mode 100644 index 0000000000..0ce2c91f2e --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddInstructionCountColumn.cs @@ -0,0 +1,20 @@ +using System.Linq; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_8_0 +{ + internal class AddInstructionCountColumn : MigrationBase + { + public AddInstructionCountColumn(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToArray(); + + if (columns.Any(x => x.TableName.InvariantEquals(Constants.DatabaseSchema.Tables.CacheInstruction) && x.ColumnName.InvariantEquals("instructionCount")) == false) + AddColumn("instructionCount"); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddMediaVersionTable.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddMediaVersionTable.cs new file mode 100644 index 0000000000..b4c0062770 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddMediaVersionTable.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Persistence.Factories; +using static Umbraco.Core.Persistence.NPocoSqlExtensions.Statics; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_8_0 +{ + internal class AddMediaVersionTable : MigrationBase + { + public AddMediaVersionTable(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.MediaVersion)) return; + + Create.Table().Do(); + MigrateMediaPaths(); + } + + private void MigrateMediaPaths() + { + // this may not be the most efficient way to do it, compared to how it's done in v7, but this + // migration should only run for v8 sites that are being developed, before v8 is released, so + // no big sites and performances don't matter here - keep it simple + + var sql = Sql() + .Select(x => x.VarcharValue, x => x.TextValue) + .AndSelect(x => Alias(x.Id, "versionId")) + .From() + .InnerJoin().On((left, right) => left.PropertyTypeId == right.Id) + .InnerJoin().On((left, right) => left.VersionId == right.Id) + .InnerJoin().On((left, right) => left.NodeId == right.NodeId) + .Where(x => x.Alias == "umbracoFile") + .Where(x => x.NodeObjectType == Constants.ObjectTypes.Media); + + var paths = new List(); + + //using QUERY = a db cursor, we won't load this all into memory first, just row by row + foreach (var row in Database.Query(sql)) + { + // if there's values then ensure there's a media path match and extract it + string mediaPath = null; + if ( + (row.varcharValue != null && ContentBaseFactory.TryMatch((string) row.varcharValue, out mediaPath)) + || (row.textValue != null && ContentBaseFactory.TryMatch((string) row.textValue, out mediaPath))) + { + paths.Add(new MediaVersionDto + { + Id = (int) row.versionId, + Path = mediaPath + }); + } + } + + // bulk insert + Database.BulkInsertRecords(paths); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddTourDataUserColumn.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddTourDataUserColumn.cs new file mode 100644 index 0000000000..cd2678205f --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddTourDataUserColumn.cs @@ -0,0 +1,21 @@ +using System.Linq; +using Umbraco.Core.Persistence.DatabaseAnnotations; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_8_0 +{ + internal class AddTourDataUserColumn : MigrationBase + { + public AddTourDataUserColumn(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToArray(); + + if (columns.Any(x => x.TableName.InvariantEquals(Constants.DatabaseSchema.Tables.User) && x.ColumnName.InvariantEquals("tourData")) == false) + AddColumn("tourData"); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddUserLoginTable.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddUserLoginTable.cs new file mode 100644 index 0000000000..7a55362072 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_8_0/AddUserLoginTable.cs @@ -0,0 +1,22 @@ +using System.Linq; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_8_0 +{ + internal class AddUserLoginTable : MigrationBase + { + public AddUserLoginTable(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.UserLogin) == false) + { + Create.Table().Do(); + } + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddIsSensitiveMemberTypeColumn.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddIsSensitiveMemberTypeColumn.cs new file mode 100644 index 0000000000..4e1a7d1470 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddIsSensitiveMemberTypeColumn.cs @@ -0,0 +1,20 @@ +using System.Linq; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_9_0 +{ + internal class AddIsSensitiveMemberTypeColumn : MigrationBase + { + public AddIsSensitiveMemberTypeColumn(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToArray(); + + if (columns.Any(x => x.TableName.InvariantEquals(Constants.DatabaseSchema.Tables.MemberType) && x.ColumnName.InvariantEquals("isSensitive")) == false) + AddColumn("isSensitive"); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoAuditTable.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoAuditTable.cs new file mode 100644 index 0000000000..e7880dfc73 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoAuditTable.cs @@ -0,0 +1,22 @@ +using System.Linq; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_9_0 +{ + internal class AddUmbracoAuditTable : MigrationBase + { + public AddUmbracoAuditTable(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.AuditEntry)) + return; + + Create.Table().Do(); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoConsentTable.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoConsentTable.cs new file mode 100644 index 0000000000..e3656f69ac --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/AddUmbracoConsentTable.cs @@ -0,0 +1,22 @@ +using System.Linq; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_9_0 +{ + internal class AddUmbracoConsentTable : MigrationBase + { + public AddUmbracoConsentTable(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.Consent)) + return; + + Create.Table().Do(); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/CreateSensitiveDataUserGroup.cs b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/CreateSensitiveDataUserGroup.cs new file mode 100644 index 0000000000..a3749f7be5 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_7_9_0/CreateSensitiveDataUserGroup.cs @@ -0,0 +1,27 @@ +using System; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_7_9_0 +{ + internal class CreateSensitiveDataUserGroup : MigrationBase + { + public CreateSensitiveDataUserGroup(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var sql = Sql() + .SelectCount() + .From() + .Where(x => x.Alias == Constants.Security.SensitiveDataGroupAlias); + + var exists = Database.ExecuteScalar(sql) > 0; + if (exists) return; + + var groupId = Database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", new UserGroupDto { StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.SensitiveDataGroupAlias, Name = "Sensitive data", DefaultPermissions = "", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-lock" }); + Database.Insert(new User2UserGroupDto { UserGroupId = Convert.ToInt32(groupId), UserId = Constants.Security.SuperId }); // add super to sensitive data + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs index 202a4f0c0a..08121df815 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs @@ -35,7 +35,7 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0 // re-create *all* keys and indexes foreach (var x in DatabaseSchemaCreator.OrderedTables) - Create.KeysAndIndexes(x.Value).Do(); + Create.KeysAndIndexes(x).Do(); // renames Database.Execute(Sql() diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/VariantsMigration.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/VariantsMigration.cs index e8b06c4a31..72241fb496 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/VariantsMigration.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/VariantsMigration.cs @@ -52,7 +52,7 @@ HAVING COUNT(v2.id) <> 1").Any()) // re-create *all* keys and indexes foreach (var x in DatabaseSchemaCreator.OrderedTables) - Create.KeysAndIndexes(x.Value).Do(); + Create.KeysAndIndexes(x).Do(); } private void MigratePropertyData() diff --git a/src/Umbraco.Core/Models/AuditEntry.cs b/src/Umbraco.Core/Models/AuditEntry.cs new file mode 100644 index 0000000000..2076e5328c --- /dev/null +++ b/src/Umbraco.Core/Models/AuditEntry.cs @@ -0,0 +1,94 @@ +using System; +using System.Reflection; +using System.Runtime.Serialization; +using Umbraco.Core.Models.Entities; + +namespace Umbraco.Core.Models +{ + /// + /// Represents an audited event. + /// + [Serializable] + [DataContract(IsReference = true)] + internal class AuditEntry : EntityBase, IAuditEntry + { + private static PropertySelectors _selectors; + + private int _performingUserId; + private string _performingDetails; + private string _performingIp; + private int _affectedUserId; + private string _affectedDetails; + private string _eventType; + private string _eventDetails; + + private static PropertySelectors Selectors => _selectors ?? (_selectors = new PropertySelectors()); + + private class PropertySelectors + { + public readonly PropertyInfo PerformingUserId = ExpressionHelper.GetPropertyInfo(x => x.PerformingUserId); + public readonly PropertyInfo PerformingDetails = ExpressionHelper.GetPropertyInfo(x => x.PerformingDetails); + public readonly PropertyInfo PerformingIp = ExpressionHelper.GetPropertyInfo(x => x.PerformingIp); + public readonly PropertyInfo AffectedUserId = ExpressionHelper.GetPropertyInfo(x => x.AffectedUserId); + public readonly PropertyInfo AffectedDetails = ExpressionHelper.GetPropertyInfo(x => x.AffectedDetails); + public readonly PropertyInfo EventType = ExpressionHelper.GetPropertyInfo(x => x.EventType); + public readonly PropertyInfo EventDetails = ExpressionHelper.GetPropertyInfo(x => x.EventDetails); + } + + /// + public int PerformingUserId + { + get => _performingUserId; + set => SetPropertyValueAndDetectChanges(value, ref _performingUserId, Selectors.PerformingUserId); + } + + /// + public string PerformingDetails + { + get => _performingDetails; + set => SetPropertyValueAndDetectChanges(value, ref _performingDetails, Selectors.PerformingDetails); + } + + /// + public string PerformingIp + { + get => _performingIp; + set => SetPropertyValueAndDetectChanges(value, ref _performingIp, Selectors.PerformingIp); + } + + /// + public DateTime EventDateUtc + { + get => CreateDate; + set => CreateDate = value; + } + + /// + public int AffectedUserId + { + get => _affectedUserId; + set => SetPropertyValueAndDetectChanges(value, ref _affectedUserId, Selectors.AffectedUserId); + } + + /// + public string AffectedDetails + { + get => _affectedDetails; + set => SetPropertyValueAndDetectChanges(value, ref _affectedDetails, Selectors.AffectedDetails); + } + + /// + public string EventType + { + get => _eventType; + set => SetPropertyValueAndDetectChanges(value, ref _eventType, Selectors.EventType); + } + + /// + public string EventDetails + { + get => _eventDetails; + set => SetPropertyValueAndDetectChanges(value, ref _eventDetails, Selectors.EventDetails); + } + } +} diff --git a/src/Umbraco.Core/Models/AuditItem.cs b/src/Umbraco.Core/Models/AuditItem.cs index 519f288ef0..6bfe32bd77 100644 --- a/src/Umbraco.Core/Models/AuditItem.cs +++ b/src/Umbraco.Core/Models/AuditItem.cs @@ -2,18 +2,29 @@ namespace Umbraco.Core.Models { - public sealed class AuditItem : EntityBase + public sealed class AuditItem : EntityBase, IAuditItem { + /// + /// Constructor for creating an item to be created + /// + /// + /// + /// + /// public AuditItem(int objectId, string comment, AuditType type, int userId) { + DisableChangeTracking(); + Id = objectId; Comment = comment; AuditType = type; UserId = userId; + + EnableChangeTracking(); } - public string Comment { get; private set; } - public AuditType AuditType { get; private set; } - public int UserId { get; private set; } + public string Comment { get; } + public AuditType AuditType { get; } + public int UserId { get; } } } diff --git a/src/Umbraco.Core/Models/Consent.cs b/src/Umbraco.Core/Models/Consent.cs new file mode 100644 index 0000000000..87dd9767a0 --- /dev/null +++ b/src/Umbraco.Core/Models/Consent.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.Serialization; +using Umbraco.Core.Models.Entities; + +namespace Umbraco.Core.Models +{ + /// + /// Represents a consent. + /// + [Serializable] + [DataContract(IsReference = true)] + internal class Consent : EntityBase, IConsent + { + private static PropertySelectors _selector; + + private bool _current; + private string _source; + private string _context; + private string _action; + private ConsentState _state; + private string _comment; + + // ReSharper disable once ClassNeverInstantiated.Local + private class PropertySelectors + { + public readonly PropertyInfo Current = ExpressionHelper.GetPropertyInfo(x => x.Current); + public readonly PropertyInfo Source = ExpressionHelper.GetPropertyInfo(x => x.Source); + public readonly PropertyInfo Context = ExpressionHelper.GetPropertyInfo(x => x.Context); + public readonly PropertyInfo Action = ExpressionHelper.GetPropertyInfo(x => x.Action); + public readonly PropertyInfo State = ExpressionHelper.GetPropertyInfo(x => x.State); + public readonly PropertyInfo Comment = ExpressionHelper.GetPropertyInfo(x => x.Comment); + } + + private static PropertySelectors Selectors => _selector ?? (_selector = new PropertySelectors()); + + /// + public bool Current + { + get => _current; + set => SetPropertyValueAndDetectChanges(value, ref _current, Selectors.Current); + } + + /// + public string Source + { + get => _source; + set + { + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException(nameof(value)); + SetPropertyValueAndDetectChanges(value, ref _source, Selectors.Source); + } + } + + /// + public string Context + { + get => _context; + set + { + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException(nameof(value)); + SetPropertyValueAndDetectChanges(value, ref _context, Selectors.Context); + } + } + + /// + public string Action + { + get => _action; + set + { + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException(nameof(value)); + SetPropertyValueAndDetectChanges(value, ref _action, Selectors.Action); + } + } + + /// + public ConsentState State + { + get => _state; + // note: we probably should validate the state here, but since the + // enum is [Flags] with many combinations, this could be expensive + set => SetPropertyValueAndDetectChanges(value, ref _state, Selectors.State); + } + + /// + public string Comment + { + get => _comment; + set => SetPropertyValueAndDetectChanges(value, ref _comment, Selectors.Comment); + } + + /// + public IEnumerable History => HistoryInternal; + + /// + /// Gets the previous states of this consent. + /// + internal List HistoryInternal { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/ConsentExtensions.cs b/src/Umbraco.Core/Models/ConsentExtensions.cs new file mode 100644 index 0000000000..fabeaf5809 --- /dev/null +++ b/src/Umbraco.Core/Models/ConsentExtensions.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Core.Models +{ + /// + /// Provides extension methods for the interface. + /// + public static class ConsentExtensions + { + /// + /// Determines whether the consent is granted. + /// + public static bool IsGranted(this IConsent consent) => (consent.State & ConsentState.Granted) > 0; + + /// + /// Determines whether the consent is revoked. + /// + public static bool IsRevoked(this IConsent consent) => (consent.State & ConsentState.Revoked) > 0; + } +} diff --git a/src/Umbraco.Core/Models/ConsentState.cs b/src/Umbraco.Core/Models/ConsentState.cs new file mode 100644 index 0000000000..ed370823f3 --- /dev/null +++ b/src/Umbraco.Core/Models/ConsentState.cs @@ -0,0 +1,38 @@ +using System; + +namespace Umbraco.Core.Models +{ + /// + /// Represents the state of a consent. + /// + [Flags] + public enum ConsentState // : int + { + // note - this is a [Flags] enumeration + // on can create detailed flags such as: + //GrantedOptIn = Granted | 0x0001 + //GrandedByForce = Granted | 0x0002 + // + // 16 situations for each Pending/Granted/Revoked should be ok + + /// + /// There is no consent. + /// + None = 0, + + /// + /// Consent is pending and has not been granted yet. + /// + Pending = 0x10000, + + /// + /// Consent has been granted. + /// + Granted = 0x20000, + + /// + /// Consent has been revoked. + /// + Revoked = 0x40000 + } +} diff --git a/src/Umbraco.Core/Models/ContentBase.cs b/src/Umbraco.Core/Models/ContentBase.cs index 3bf614a3ef..a1df720a83 100644 --- a/src/Umbraco.Core/Models/ContentBase.cs +++ b/src/Umbraco.Core/Models/ContentBase.cs @@ -223,9 +223,8 @@ namespace Umbraco.Core.Models #region Dirty - /// - /// Resets dirty properties. - /// + /// + /// Overriden to include user properties. public override void ResetDirtyProperties(bool rememberDirty) { base.ResetDirtyProperties(rememberDirty); @@ -235,17 +234,15 @@ namespace Umbraco.Core.Models prop.ResetDirtyProperties(rememberDirty); } - /// - /// Gets a value indicating whether the current entity is dirty. - /// + /// + /// Overriden to include user properties. public override bool IsDirty() { return IsEntityDirty() || this.IsAnyUserPropertyDirty(); } - /// - /// Gets a value indicating whether the current entity was dirty. - /// + /// + /// Overriden to include user properties. public override bool WasDirty() { return WasEntityDirty() || this.WasAnyUserPropertyDirty(); @@ -267,9 +264,8 @@ namespace Umbraco.Core.Models return base.WasDirty(); } - /// - /// Gets a value indicating whether a user property is dirty. - /// + /// + /// Overriden to include user properties. public override bool IsPropertyDirty(string propertyName) { if (base.IsPropertyDirty(propertyName)) @@ -278,9 +274,8 @@ namespace Umbraco.Core.Models return Properties.Contains(propertyName) && Properties[propertyName].IsDirty(); } - /// - /// Gets a value indicating whether a user property was dirty. - /// + /// + /// Overriden to include user properties. public override bool WasPropertyDirty(string propertyName) { if (base.WasPropertyDirty(propertyName)) @@ -289,6 +284,24 @@ namespace Umbraco.Core.Models return Properties.Contains(propertyName) && Properties[propertyName].WasDirty(); } + /// + /// Overriden to include user properties. + public override IEnumerable GetDirtyProperties() + { + var instanceProperties = base.GetDirtyProperties(); + var propertyTypes = Properties.Where(x => x.IsDirty()).Select(x => x.Alias); + return instanceProperties.Concat(propertyTypes); + } + + /// + /// Overriden to include user properties. + public override IEnumerable GetWereDirtyProperties() + { + var instanceProperties = base.GetWereDirtyProperties(); + var propertyTypes = Properties.Where(x => x.WasDirty()).Select(x => x.Alias); + return instanceProperties.Concat(propertyTypes); + } + #endregion } } diff --git a/src/Umbraco.Core/Models/ContentType.cs b/src/Umbraco.Core/Models/ContentType.cs index d0f3edea8b..a7418a1441 100644 --- a/src/Umbraco.Core/Models/ContentType.cs +++ b/src/Umbraco.Core/Models/ContentType.cs @@ -97,11 +97,13 @@ namespace Umbraco.Core.Models [DataMember] public IEnumerable AllowedTemplates { - get { return _allowedTemplates; } + get => _allowedTemplates; set { - SetPropertyValueAndDetectChanges(value, ref _allowedTemplates, Ps.Value.AllowedTemplatesSelector, - Ps.Value.TemplateComparer); + SetPropertyValueAndDetectChanges(value, ref _allowedTemplates, Ps.Value.AllowedTemplatesSelector, Ps.Value.TemplateComparer); + + if (_allowedTemplates.Any(x => x.Id == _defaultTemplate) == false) + DefaultTemplateId = 0; } } diff --git a/src/Umbraco.Core/Models/IAuditEntry.cs b/src/Umbraco.Core/Models/IAuditEntry.cs new file mode 100644 index 0000000000..c097f84752 --- /dev/null +++ b/src/Umbraco.Core/Models/IAuditEntry.cs @@ -0,0 +1,60 @@ +using System; +using Umbraco.Core.Models.Entities; + +namespace Umbraco.Core.Models +{ + /// + /// Represents an audited event. + /// + /// + /// The free-form details properties can be used to capture relevant infos (for example, + /// a user email and identifier) at the time of the audited event, even though they may change + /// later on - but we want to keep a track of their value at that time. + /// Depending on audit loggers, these properties can be purely free-form text, or + /// contain json serialized objects. + /// + public interface IAuditEntry : IEntity, IRememberBeingDirty + { + /// + /// Gets or sets the identifier of the user triggering the audited event. + /// + int PerformingUserId { get; set; } + + /// + /// Gets or sets free-form details about the user triggering the audited event. + /// + string PerformingDetails { get; set; } + + /// + /// Gets or sets the IP address or the request triggering the audited event. + /// + string PerformingIp { get; set; } + + /// + /// Gets or sets the date and time of the audited event. + /// + DateTime EventDateUtc { get; set; } + + /// + /// Gets or sets the identifier of the user affected by the audited event. + /// + /// Not used when no single user is affected by the event. + int AffectedUserId { get; set; } + + /// + /// Gets or sets free-form details about the entity affected by the audited event. + /// + /// The entity affected by the event can be another user, a member... + string AffectedDetails { get; set; } + + /// + /// Gets or sets the type of the audited event. + /// + string EventType { get; set; } + + /// + /// Gets or sets free-form details about the audited event. + /// + string EventDetails { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/IAuditItem.cs b/src/Umbraco.Core/Models/IAuditItem.cs new file mode 100644 index 0000000000..9416e2a055 --- /dev/null +++ b/src/Umbraco.Core/Models/IAuditItem.cs @@ -0,0 +1,12 @@ +using System; +using Umbraco.Core.Models.Entities; + +namespace Umbraco.Core.Models +{ + public interface IAuditItem : IEntity + { + string Comment { get; } + AuditType AuditType { get; } + int UserId { get; } + } +} diff --git a/src/Umbraco.Core/Models/IConsent.cs b/src/Umbraco.Core/Models/IConsent.cs new file mode 100644 index 0000000000..7e0156fd6e --- /dev/null +++ b/src/Umbraco.Core/Models/IConsent.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Umbraco.Core.Models.Entities; + +namespace Umbraco.Core.Models +{ + /// + /// Represents a consent state. + /// + /// + /// A consent is fully identified by a source (whoever is consenting), a context (for + /// example, an application), and an action (whatever is consented). + /// A consent state registers the state of the consent (granted, revoked...). + /// + public interface IConsent : IEntity, IRememberBeingDirty + { + /// + /// Determines whether the consent entity represents the current state. + /// + bool Current { get; } + + /// + /// Gets the unique identifier of whoever is consenting. + /// + string Source { get; } + + /// + /// Gets the unique identifier of the context of the consent. + /// + /// + /// Represents the domain, application, scope... of the action. + /// When the action is a Udi, this should be the Udi type. + /// + string Context { get; } + + /// + /// Gets the unique identifier of the consented action. + /// + string Action { get; } + + /// + /// Gets the state of the consent. + /// + ConsentState State { get; } + + /// + /// Gets some additional free text. + /// + string Comment { get; } + + /// + /// Gets the previous states of this consent. + /// + IEnumerable History { get; } + } +} diff --git a/src/Umbraco.Core/Models/IMemberType.cs b/src/Umbraco.Core/Models/IMemberType.cs index a1ce10dac8..9596d88cca 100644 --- a/src/Umbraco.Core/Models/IMemberType.cs +++ b/src/Umbraco.Core/Models/IMemberType.cs @@ -19,6 +19,13 @@ /// bool MemberCanViewProperty(string propertyTypeAlias); + /// + /// Gets a boolean indicating whether a Property is marked as storing sensitive values on the Members profile. + /// + /// PropertyType Alias of the Property to check + /// + bool IsSensitiveProperty(string propertyTypeAlias); + /// /// Sets a boolean indicating whether a Property is editable by the Member. /// @@ -32,5 +39,12 @@ /// PropertyType Alias of the Property to set /// Boolean value, true or false void SetMemberCanViewProperty(string propertyTypeAlias, bool value); + + /// + /// Sets a boolean indicating whether a Property is a sensitive value on the Members profile. + /// + /// PropertyType Alias of the Property to set + /// Boolean value, true or false + void SetIsSensitiveProperty(string propertyTypeAlias, bool value); } } diff --git a/src/Umbraco.Core/Models/Member.cs b/src/Umbraco.Core/Models/Member.cs index f09094f466..a45b1e356b 100644 --- a/src/Umbraco.Core/Models/Member.cs +++ b/src/Umbraco.Core/Models/Member.cs @@ -168,7 +168,19 @@ namespace Umbraco.Core.Models public string RawPasswordValue { get { return _rawPasswordValue; } - set { SetPropertyValueAndDetectChanges(value, ref _rawPasswordValue, Ps.Value.PasswordSelector); } + set + { + if (value == null) + { + //special case, this is used to ensure that the password is not updated when persisting, in this case + //we don't want to track changes either + _rawPasswordValue = null; + } + else + { + SetPropertyValueAndDetectChanges(value, ref _rawPasswordValue, Ps.Value.PasswordSelector); + } + } } /// diff --git a/src/Umbraco.Core/Models/MemberType.cs b/src/Umbraco.Core/Models/MemberType.cs index df087920f8..76450e9115 100644 --- a/src/Umbraco.Core/Models/MemberType.cs +++ b/src/Umbraco.Core/Models/MemberType.cs @@ -79,7 +79,7 @@ namespace Umbraco.Core.Models } /// - /// Gets or Sets a Dictionary of Tuples (MemberCanEdit, VisibleOnProfile) by the PropertyTypes' alias. + /// Gets or Sets a Dictionary of Tuples (MemberCanEdit, VisibleOnProfile, IsSensitive) by the PropertyTypes' alias. /// [DataMember] internal IDictionary MemberTypePropertyTypes { get; private set; } @@ -91,7 +91,7 @@ namespace Umbraco.Core.Models /// public bool MemberCanEditProperty(string propertyTypeAlias) { - return MemberTypePropertyTypes.ContainsKey(propertyTypeAlias) && MemberTypePropertyTypes[propertyTypeAlias].IsEditable; + return MemberTypePropertyTypes.TryGetValue(propertyTypeAlias, out var propertyProfile) && propertyProfile.IsEditable; } /// @@ -101,7 +101,16 @@ namespace Umbraco.Core.Models /// public bool MemberCanViewProperty(string propertyTypeAlias) { - return MemberTypePropertyTypes.ContainsKey(propertyTypeAlias) && MemberTypePropertyTypes[propertyTypeAlias].IsVisible; + return MemberTypePropertyTypes.TryGetValue(propertyTypeAlias, out var propertyProfile) && propertyProfile.IsVisible; + } + /// + /// Gets a boolean indicating whether a Property is marked as storing sensitive values on the Members profile. + /// + /// PropertyType Alias of the Property to check + /// + public bool IsSensitiveProperty(string propertyTypeAlias) + { + return MemberTypePropertyTypes.TryGetValue(propertyTypeAlias, out var propertyProfile) && propertyProfile.IsSensitive; } /// @@ -111,13 +120,13 @@ namespace Umbraco.Core.Models /// Boolean value, true or false public void SetMemberCanEditProperty(string propertyTypeAlias, bool value) { - if (MemberTypePropertyTypes.ContainsKey(propertyTypeAlias)) + if (MemberTypePropertyTypes.TryGetValue(propertyTypeAlias, out var propertyProfile)) { - MemberTypePropertyTypes[propertyTypeAlias].IsEditable = value; + propertyProfile.IsEditable = value; } else { - var tuple = new MemberTypePropertyProfileAccess(false, value); + var tuple = new MemberTypePropertyProfileAccess(false, value, false); MemberTypePropertyTypes.Add(propertyTypeAlias, tuple); } } @@ -129,13 +138,31 @@ namespace Umbraco.Core.Models /// Boolean value, true or false public void SetMemberCanViewProperty(string propertyTypeAlias, bool value) { - if (MemberTypePropertyTypes.ContainsKey(propertyTypeAlias)) + if (MemberTypePropertyTypes.TryGetValue(propertyTypeAlias, out var propertyProfile)) { - MemberTypePropertyTypes[propertyTypeAlias].IsVisible = value; + propertyProfile.IsVisible = value; } else { - var tuple = new MemberTypePropertyProfileAccess(value, false); + var tuple = new MemberTypePropertyProfileAccess(value, false, false); + MemberTypePropertyTypes.Add(propertyTypeAlias, tuple); + } + } + + /// + /// Sets a boolean indicating whether a Property is a sensitive value on the Members profile. + /// + /// PropertyType Alias of the Property to set + /// Boolean value, true or false + public void SetIsSensitiveProperty(string propertyTypeAlias, bool value) + { + if (MemberTypePropertyTypes.TryGetValue(propertyTypeAlias, out var propertyProfile)) + { + propertyProfile.IsSensitive = value; + } + else + { + var tuple = new MemberTypePropertyProfileAccess(false, false, true); MemberTypePropertyTypes.Add(propertyTypeAlias, tuple); } } diff --git a/src/Umbraco.Core/Models/MemberTypePropertyProfileAccess.cs b/src/Umbraco.Core/Models/MemberTypePropertyProfileAccess.cs index 0f6b2a3dce..386fdf560b 100644 --- a/src/Umbraco.Core/Models/MemberTypePropertyProfileAccess.cs +++ b/src/Umbraco.Core/Models/MemberTypePropertyProfileAccess.cs @@ -5,13 +5,15 @@ /// internal class MemberTypePropertyProfileAccess { - public MemberTypePropertyProfileAccess(bool isVisible, bool isEditable) + public MemberTypePropertyProfileAccess(bool isVisible, bool isEditable, bool isSenstive) { IsVisible = isVisible; IsEditable = isEditable; + IsSensitive = isSenstive; } public bool IsVisible { get; set; } public bool IsEditable { get; set; } + public bool IsSensitive { get; set; } } } diff --git a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs index c58f69d223..31b5f2e513 100644 --- a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs +++ b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs @@ -188,11 +188,11 @@ namespace Umbraco.Core.Models Language, /// - /// Document + /// Document Blueprint /// [UmbracoObjectType(Constants.ObjectTypes.Strings.DocumentBlueprint, typeof(IContent))] [FriendlyName("DocumentBlueprint")] - [UmbracoUdiType(Constants.UdiEntityType.DocumentBluePrint)] + [UmbracoUdiType(Constants.UdiEntityType.DocumentBlueprint)] DocumentBlueprint, /// diff --git a/src/Umbraco.Core/Models/UserExtensions.cs b/src/Umbraco.Core/Models/UserExtensions.cs index 2ec9f43a53..f66f0b4ef7 100644 --- a/src/Umbraco.Core/Models/UserExtensions.cs +++ b/src/Umbraco.Core/Models/UserExtensions.cs @@ -48,12 +48,11 @@ namespace Umbraco.Core.Models /// Tries to lookup the user's gravatar to see if the endpoint can be reached, if so it returns the valid URL /// /// - /// /// /// /// A list of 5 different sized avatar URLs /// - internal static string[] GetCurrentUserAvatarUrls(this IUser user, IUserService userService, ICacheProvider staticCache) + internal static string[] GetUserAvatarUrls(this IUser user, ICacheProvider staticCache) { //check if the user has explicitly removed all avatars including a gravatar, this will be possible and the value will be "none" if (user.Avatar == "none") @@ -276,6 +275,16 @@ namespace Umbraco.Core.Models return false; } + /// + /// Determines whether this user has access to view sensitive data + /// + /// + public static bool HasAccessToSensitiveData(this IUser user) + { + if (user == null) throw new ArgumentNullException("user"); + return user.Groups != null && user.Groups.Any(x => x.Alias == Constants.Security.SensitiveDataGroupAlias); + } + // calc. start nodes, combining groups' and user's, and excluding what's in the bin public static int[] CalculateContentStartNodeIds(this IUser user, IEntityService entityService) { diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 68f9435219..59eee2fd80 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -28,6 +28,7 @@ namespace Umbraco.Core public const string ContentVersion = TableNamePrefix + "ContentVersion"; public const string Document = TableNamePrefix + "Document"; public const string DocumentVersion = TableNamePrefix + "DocumentVersion"; + public const string MediaVersion = TableNamePrefix + "MediaVersion"; public const string PropertyType = /*TableNamePrefix*/ "cms" + "PropertyType"; public const string PropertyTypeGroup = /*TableNamePrefix*/ "cms" + "PropertyTypeGroup"; diff --git a/src/Umbraco.Core/Persistence/Dtos/MediaDto.cs b/src/Umbraco.Core/Persistence/Dtos/MediaDto.cs new file mode 100644 index 0000000000..6990a891c4 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/MediaDto.cs @@ -0,0 +1,21 @@ +using NPoco; + +namespace Umbraco.Core.Persistence.Dtos +{ + // this is a special Dto that does not have a corresponding table + // and is only used in our code to represent a media item, similar + // to document items. + + internal class MediaDto + { + public int NodeId { get; set; } + + [ResultColumn] + [Reference(ReferenceType.OneToOne, ReferenceMemberName = "NodeId")] + public ContentDto ContentDto { get; set; } + + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public MediaVersionDto MediaVersionDto { get; set; } + } +} diff --git a/src/Umbraco.Core/Persistence/Dtos/MediaVersionDto.cs b/src/Umbraco.Core/Persistence/Dtos/MediaVersionDto.cs new file mode 100644 index 0000000000..e27a99a2ae --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/MediaVersionDto.cs @@ -0,0 +1,25 @@ +using NPoco; +using Umbraco.Core.Persistence.DatabaseAnnotations; + +namespace Umbraco.Core.Persistence.Dtos +{ + [TableName(Constants.DatabaseSchema.Tables.MediaVersion)] + [PrimaryKey("id", AutoIncrement = false)] + [ExplicitColumns] + internal class MediaVersionDto + { + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = false)] + [ForeignKey(typeof(ContentVersionDto))] + [Index(IndexTypes.UniqueNonClustered, Name = "IX_" + Constants.DatabaseSchema.Tables.MediaVersion, ForColumns = "id, path")] + public int Id { get; set; } + + [Column("path")] + [NullSetting(NullSetting = NullSettings.Null)] + public string Path { get; set; } + + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public ContentVersionDto ContentVersionDto { get; set; } + } +} diff --git a/src/Umbraco.Core/Persistence/Factories/AuditEntryFactory.cs b/src/Umbraco.Core/Persistence/Factories/AuditEntryFactory.cs new file mode 100644 index 0000000000..bbf6058055 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Factories/AuditEntryFactory.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Persistence.Factories +{ + internal static class AuditEntryFactory + { + public static IEnumerable BuildEntities(IEnumerable dtos) + { + return dtos.Select(BuildEntity).ToList(); + } + + public static IAuditEntry BuildEntity(AuditEntryDto dto) + { + var entity = new AuditEntry + { + Id = dto.Id, + PerformingUserId = dto.PerformingUserId, + PerformingDetails = dto.PerformingDetails, + PerformingIp = dto.PerformingIp, + EventDateUtc = dto.EventDateUtc, + AffectedUserId = dto.AffectedUserId, + AffectedDetails = dto.AffectedDetails, + EventType = dto.EventType, + EventDetails = dto.EventDetails + }; + + //on initial construction we don't want to have dirty properties tracked + // http://issues.umbraco.org/issue/U4-1946 + entity.ResetDirtyProperties(false); + return entity; + } + + public static AuditEntryDto BuildDto(IAuditEntry entity) + { + return new AuditEntryDto + { + Id = entity.Id, + PerformingUserId = entity.PerformingUserId, + PerformingDetails = entity.PerformingDetails, + PerformingIp = entity.PerformingIp, + EventDateUtc = entity.EventDateUtc, + AffectedUserId = entity.AffectedUserId, + AffectedDetails = entity.AffectedDetails, + EventType = entity.EventType, + EventDetails = entity.EventDetails + }; + } + } +} diff --git a/src/Umbraco.Core/Persistence/Factories/ConsentFactory.cs b/src/Umbraco.Core/Persistence/Factories/ConsentFactory.cs new file mode 100644 index 0000000000..5c3b90fee8 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Factories/ConsentFactory.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Persistence.Factories +{ + internal static class ConsentFactory + { + public static IEnumerable BuildEntities(IEnumerable dtos) + { + var ix = new Dictionary(); + var output = new List(); + + foreach (var dto in dtos) + { + var k = dto.Source + "::" + dto.Context + "::" + dto.Action; + + var consent = new Consent + { + Id = dto.Id, + Current = dto.Current, + CreateDate = dto.CreateDate, + Source = dto.Source, + Context = dto.Context, + Action = dto.Action, + State = (ConsentState) dto.State, // assume value is valid + Comment = dto.Comment + }; + + //on initial construction we don't want to have dirty properties tracked + // http://issues.umbraco.org/issue/U4-1946 + consent.ResetDirtyProperties(false); + + if (ix.TryGetValue(k, out var current)) + { + if (current.HistoryInternal == null) + current.HistoryInternal = new List(); + current.HistoryInternal.Add(consent); + } + else + { + ix[k] = consent; + output.Add(consent); + } + } + + return output; + } + + public static ConsentDto BuildDto(IConsent entity) + { + return new ConsentDto + { + Id = entity.Id, + Current = entity.Current, + CreateDate = entity.CreateDate, + Source = entity.Source, + Context = entity.Context, + Action = entity.Action, + State = (int) entity.State, + Comment = entity.Comment + }; + } + } +} diff --git a/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs b/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs index c48ea4feca..b1a0a35432 100644 --- a/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs @@ -1,4 +1,5 @@ using System; +using System.Text.RegularExpressions; using Umbraco.Core.Models; using Umbraco.Core.Persistence.Dtos; @@ -6,6 +7,8 @@ namespace Umbraco.Core.Persistence.Factories { internal class ContentBaseFactory { + private static readonly Regex MediaPathPattern = new Regex(@"(/media/.+?)(?:['""]|$)", RegexOptions.Compiled); + /// /// Builds an IContent item from a dto and content type. /// @@ -177,11 +180,18 @@ namespace Umbraco.Core.Persistence.Factories /// /// Buils a dto from an IMedia item. /// - public static ContentDto BuildDto(IMedia entity) + public static MediaDto BuildDto(IMedia entity) { var contentBase = (Models.Media) entity; - var dto = BuildContentDto(contentBase, Constants.ObjectTypes.Media); - dto.ContentVersionDto = BuildContentVersionDto(contentBase, dto); + var contentDto = BuildContentDto(contentBase, Constants.ObjectTypes.Media); + + var dto = new MediaDto + { + NodeId = entity.Id, + ContentDto = contentDto, + MediaVersionDto = BuildMediaVersionDto(contentBase, contentDto) + }; + return dto; } @@ -273,5 +283,37 @@ namespace Umbraco.Core.Persistence.Factories return dto; } + + private static MediaVersionDto BuildMediaVersionDto(Models.Media entity, ContentDto contentDto) + { + // try to get a path from the string being stored for media + // fixme - only considering umbracoFile ?! + + TryMatch(entity.GetValue("umbracoFile"), out var path); + + var dto = new MediaVersionDto + { + Id = entity.VersionId, + Path = path, + + ContentVersionDto = BuildContentVersionDto(entity, contentDto) + }; + + return dto; + } + + // fixme - this should NOT be here?! + // more dark magic ;-( + internal static bool TryMatch(string text, out string path) + { + path = null; + if (string.IsNullOrWhiteSpace(text)) return false; + + var m = MediaPathPattern.Match(text); + if (!m.Success || m.Groups.Count != 2) return false; + + path = m.Groups[1].Value; + return true; + } } } diff --git a/src/Umbraco.Core/Persistence/Factories/ContentTypeFactory.cs b/src/Umbraco.Core/Persistence/Factories/ContentTypeFactory.cs index 9cd9f8ab0a..f592bfddcb 100644 --- a/src/Umbraco.Core/Persistence/Factories/ContentTypeFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/ContentTypeFactory.cs @@ -82,7 +82,8 @@ namespace Umbraco.Core.Persistence.Factories NodeId = entity.Id, PropertyTypeId = x.Id, CanEdit = memberType.MemberCanEditProperty(x.Alias), - ViewOnProfile = memberType.MemberCanViewProperty(x.Alias) + ViewOnProfile = memberType.MemberCanViewProperty(x.Alias), + IsSensitive = memberType.IsSensitiveProperty(x.Alias) }).ToList(); return dtos; } diff --git a/src/Umbraco.Core/Persistence/Factories/MemberTypeReadOnlyFactory.cs b/src/Umbraco.Core/Persistence/Factories/MemberTypeReadOnlyFactory.cs index 848bcb3909..6f79f03c73 100644 --- a/src/Umbraco.Core/Persistence/Factories/MemberTypeReadOnlyFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/MemberTypeReadOnlyFactory.cs @@ -57,10 +57,10 @@ namespace Umbraco.Core.Persistence.Factories //Add the standard PropertyType to the current list propertyTypes.Add(standardPropertyType.Value); - - //Internal dictionary for adding "MemberCanEdit" and "VisibleOnProfile" properties to each PropertyType + + //Internal dictionary for adding "MemberCanEdit", "VisibleOnProfile", "IsSensitive" properties to each PropertyType memberType.MemberTypePropertyTypes.Add(standardPropertyType.Key, - new MemberTypePropertyProfileAccess(false, false)); + new MemberTypePropertyProfileAccess(false, false, false)); } memberType.NoGroupPropertyTypes = propertyTypes; @@ -103,7 +103,7 @@ namespace Umbraco.Core.Persistence.Factories { //Internal dictionary for adding "MemberCanEdit" and "VisibleOnProfile" properties to each PropertyType memberType.MemberTypePropertyTypes.Add(typeDto.Alias, - new MemberTypePropertyProfileAccess(typeDto.ViewOnProfile, typeDto.CanEdit)); + new MemberTypePropertyProfileAccess(typeDto.ViewOnProfile, typeDto.CanEdit, typeDto.IsSensitive)); var tempGroupDto = groupDto; @@ -158,7 +158,7 @@ namespace Umbraco.Core.Persistence.Factories { //Internal dictionary for adding "MemberCanEdit" and "VisibleOnProfile" properties to each PropertyType memberType.MemberTypePropertyTypes.Add(typeDto.Alias, - new MemberTypePropertyProfileAccess(typeDto.ViewOnProfile, typeDto.CanEdit)); + new MemberTypePropertyProfileAccess(typeDto.ViewOnProfile, typeDto.CanEdit, typeDto.IsSensitive)); //ensures that any built-in membership properties have their correct dbtype assigned no matter //what the underlying data type is diff --git a/src/Umbraco.Core/Persistence/Factories/UserFactory.cs b/src/Umbraco.Core/Persistence/Factories/UserFactory.cs index f9bf830ceb..394477cb51 100644 --- a/src/Umbraco.Core/Persistence/Factories/UserFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/UserFactory.cs @@ -36,6 +36,7 @@ namespace Umbraco.Core.Persistence.Factories user.Avatar = dto.Avatar; user.EmailConfirmedDate = dto.EmailConfirmedDate; user.InvitedDate = dto.InvitedDate; + user.TourData = dto.TourData; // reset dirty initial properties (U4-1946) user.ResetDirtyProperties(false); @@ -68,7 +69,8 @@ namespace Umbraco.Core.Persistence.Factories UpdateDate = entity.UpdateDate, Avatar = entity.Avatar, EmailConfirmedDate = entity.EmailConfirmedDate, - InvitedDate = entity.InvitedDate + InvitedDate = entity.InvitedDate, + TourData = entity.TourData }; foreach (var startNodeId in entity.StartContentIds) diff --git a/src/Umbraco.Core/Persistence/Mappers/AuditEntryMapper.cs b/src/Umbraco.Core/Persistence/Mappers/AuditEntryMapper.cs new file mode 100644 index 0000000000..e8d6d72f85 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Mappers/AuditEntryMapper.cs @@ -0,0 +1,40 @@ +using System.Collections.Concurrent; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Persistence.Mappers +{ + /// + /// Represents a mapper for audit entry entities. + /// + [MapperFor(typeof(IAuditEntry))] + [MapperFor(typeof(AuditEntry))] + public sealed class AuditEntryMapper : BaseMapper + { + private static readonly ConcurrentDictionary PropertyInfoCacheInstance = new ConcurrentDictionary(); + + internal override ConcurrentDictionary PropertyInfoCache => PropertyInfoCacheInstance; + + /// + /// Initializes a new instance of the class. + /// + public AuditEntryMapper() + { + // note: why the base ctor does not invoke BuildMap is a mystery to me + BuildMap(); + } + + protected override void BuildMap() + { + CacheMap(entity => entity.Id, dto => dto.Id); + CacheMap(entity => entity.PerformingUserId, dto => dto.PerformingUserId); + CacheMap(entity => entity.PerformingDetails, dto => dto.PerformingDetails); + CacheMap(entity => entity.PerformingIp, dto => dto.PerformingIp); + CacheMap(entity => entity.EventDateUtc, dto => dto.EventDateUtc); + CacheMap(entity => entity.AffectedUserId, dto => dto.AffectedUserId); + CacheMap(entity => entity.AffectedDetails, dto => dto.AffectedDetails); + CacheMap(entity => entity.EventType, dto => dto.EventType); + CacheMap(entity => entity.EventDetails, dto => dto.EventDetails); + } + } +} diff --git a/src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs b/src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs new file mode 100644 index 0000000000..a8f76944b7 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Persistence.Mappers +{ + [MapperFor(typeof(AuditItem))] + [MapperFor(typeof(IAuditItem))] + public sealed class AuditMapper : BaseMapper + { + private static readonly ConcurrentDictionary PropertyInfoCacheInstance = new ConcurrentDictionary(); + + internal override ConcurrentDictionary PropertyInfoCache => PropertyInfoCacheInstance; + + public AuditMapper() + { + BuildMap(); + } + + protected override void BuildMap() + { + if (PropertyInfoCache.IsEmpty) + { + CacheMap(src => src.Id, dto => dto.NodeId); + CacheMap(src => src.CreateDate, dto => dto.Datestamp); + CacheMap(src => src.UserId, dto => dto.UserId); + CacheMap(src => src.AuditType, dto => dto.Header); + CacheMap(src => src.Comment, dto => dto.Comment); + } + } + } +} diff --git a/src/Umbraco.Core/Persistence/Mappers/ConsentMapper.cs b/src/Umbraco.Core/Persistence/Mappers/ConsentMapper.cs new file mode 100644 index 0000000000..9df1f68fd0 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Mappers/ConsentMapper.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Persistence.Mappers +{ + /// + /// Represents a mapper for consent entities. + /// + [MapperFor(typeof(IConsent))] + [MapperFor(typeof(Consent))] + public sealed class ConsentMapper : BaseMapper + { + private static readonly ConcurrentDictionary PropertyInfoCacheInstance = new ConcurrentDictionary(); + + internal override ConcurrentDictionary PropertyInfoCache => PropertyInfoCacheInstance; + + /// + /// Initializes a new instance of the class. + /// + public ConsentMapper() + { + // note: why the base ctor does not invoke BuildMap is a mystery to me + BuildMap(); + } + + protected override void BuildMap() + { + CacheMap(entity => entity.Id, dto => dto.Id); + CacheMap(entity => entity.Current, dto => dto.Current); + CacheMap(entity => entity.CreateDate, dto => dto.CreateDate); + CacheMap(entity => entity.Source, dto => dto.Source); + CacheMap(entity => entity.Context, dto => dto.Context); + CacheMap(entity => entity.Action, dto => dto.Action); + CacheMap(entity => entity.State, dto => dto.State); + CacheMap(entity => entity.Comment, dto => dto.Comment); + } + } +} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 7ffdaa909c..a2854ea1be 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -326,10 +326,23 @@ + + + + + + + + + + + + + @@ -342,6 +355,9 @@ + + + @@ -349,7 +365,14 @@ + + + + + + + diff --git a/src/Umbraco.Tests/Migrations/AdvancedMigrationTests.cs b/src/Umbraco.Tests/Migrations/AdvancedMigrationTests.cs index 9ae6535662..cdab84ddb9 100644 --- a/src/Umbraco.Tests/Migrations/AdvancedMigrationTests.cs +++ b/src/Umbraco.Tests/Migrations/AdvancedMigrationTests.cs @@ -240,9 +240,9 @@ namespace Umbraco.Tests.Migrations foreach (var x in DatabaseSchemaCreator.OrderedTables) { // ok - for tests, restrict to Node - if (x.Value != typeof(NodeDto)) continue; + if (x != typeof(NodeDto)) continue; - Create.KeysAndIndexes(x.Value).Do(); + Create.KeysAndIndexes(x).Do(); } } } From 62c5f0f1ef1f64c3e07240b40703075bb8fd0337 Mon Sep 17 00:00:00 2001 From: Stephan Date: Thu, 22 Mar 2018 11:24:12 +0100 Subject: [PATCH 06/15] Port v7@2aa0dfb2c5 - WIP --- src/Umbraco.Core/Persistence/LocalDb.cs | 114 +++++++-------- .../Persistence/PocoDataDataReader.cs | 7 +- .../Repositories/IAuditEntryRepository.cs | 22 +++ .../Repositories/IAuditRepository.cs | 32 +++- .../Repositories/IConsentRepository.cs | 15 ++ .../Repositories/IMemberGroupRepository.cs | 2 + .../Repositories/IUserRepository.cs | 6 + .../Implement/AuditEntryRepository.cs | 137 ++++++++++++++++++ .../Repositories/Implement/AuditRepository.cs | 82 +++++++++-- .../Implement/ConsentRepository.cs | 101 +++++++++++++ .../Repositories/Implement/MediaRepository.cs | 76 ++++------ .../Implement/MemberGroupRepository.cs | 34 ++--- .../Implement/MemberTypeRepository.cs | 3 +- .../Repositories/Implement/SimilarNodeName.cs | 8 +- .../Implement/UserGroupRepository.cs | 4 + .../Repositories/Implement/UserRepository.cs | 91 +++++++++++- src/Umbraco.Core/Umbraco.Core.csproj | 4 + 17 files changed, 590 insertions(+), 148 deletions(-) create mode 100644 src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs create mode 100644 src/Umbraco.Core/Persistence/Repositories/IConsentRepository.cs create mode 100644 src/Umbraco.Core/Persistence/Repositories/Implement/AuditEntryRepository.cs create mode 100644 src/Umbraco.Core/Persistence/Repositories/Implement/ConsentRepository.cs diff --git a/src/Umbraco.Core/Persistence/LocalDb.cs b/src/Umbraco.Core/Persistence/LocalDb.cs index 7b5ea196ea..995508ecf7 100644 --- a/src/Umbraco.Core/Persistence/LocalDb.cs +++ b/src/Umbraco.Core/Persistence/LocalDb.cs @@ -24,6 +24,7 @@ namespace Umbraco.Core.Persistence { private int _version; private bool _hasVersion; + private string _exe; #region Availability & Version @@ -84,16 +85,31 @@ namespace Umbraco.Core.Persistence { _hasVersion = true; _version = -1; + _exe = null; var programFiles = Environment.GetEnvironmentVariable("ProgramFiles"); - if (programFiles == null) return; + + // MS SQL Server installs in e.g. "C:\Program Files\Microsoft SQL Server", so + // we want to detect it in "%ProgramFiles%\Microsoft SQL Server" - however, if + // Umbraco runs as a 32bits process (e.g. IISExpress configured as 32bits) + // on a 64bits system, %ProgramFiles% will point to "C:\Program Files (x86)" + // and SQL Server cannot be found. But then, %ProgramW6432% will point to + // the original "C:\Program Files". Using it to fix the path. + // see also: MSDN doc for WOW64 implementation + // + var programW6432 = Environment.GetEnvironmentVariable("ProgramW6432"); + if (string.IsNullOrWhiteSpace(programW6432) == false && programW6432 != programFiles) + programFiles = programW6432; + + if (string.IsNullOrWhiteSpace(programFiles)) return; // detect 14, 13, 12, 11 for (var i = 14; i > 10; i--) { - var path = Path.Combine(programFiles, string.Format(@"Microsoft SQL Server\{0}0\Tools\Binn\SqlLocalDB.exe", i)); - if (File.Exists(path) == false) continue; + var exe = Path.Combine(programFiles, $@"Microsoft SQL Server\{i}0\Tools\Binn\SqlLocalDB.exe"); + if (File.Exists(exe) == false) continue; _version = i; + _exe = exe; break; } } @@ -110,8 +126,7 @@ namespace Umbraco.Core.Persistence public string[] GetInstances() { EnsureAvailable(); - string output, error; - var rc = ExecuteSqlLocalDb("i", out output, out error); // info + var rc = ExecuteSqlLocalDb("i", out var output, out var error); // info if (rc != 0 || error != string.Empty) return null; return output.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); } @@ -138,8 +153,7 @@ namespace Umbraco.Core.Persistence public bool CreateInstance(string instanceName) { EnsureAvailable(); - string output, error; - return ExecuteSqlLocalDb(string.Format("c \"{0}\"", instanceName), out output, out error) == 0 && error == string.Empty; + return ExecuteSqlLocalDb($"c \"{instanceName}\"", out _, out var error) == 0 && error == string.Empty; } /// @@ -160,9 +174,8 @@ namespace Umbraco.Core.Persistence instance.DropDatabases(); // else the files remain // -i force NOWAIT, -k kills - string output, error; - return ExecuteSqlLocalDb(string.Format("p \"{0}\" -i", instanceName), out output, out error) == 0 && error == string.Empty - && ExecuteSqlLocalDb(string.Format("d \"{0}\"", instanceName), out output, out error) == 0 && error == string.Empty; + return ExecuteSqlLocalDb($"p \"{instanceName}\" -i", out _, out var error) == 0 && error == string.Empty + && ExecuteSqlLocalDb($"d \"{instanceName}\"", out _, out error) == 0 && error == string.Empty; } /// @@ -180,8 +193,7 @@ namespace Umbraco.Core.Persistence if (InstanceExists(instanceName) == false) return true; // -i force NOWAIT, -k kills - string output, error; - return ExecuteSqlLocalDb(string.Format("p \"{0}\" -i", instanceName), out output, out error) == 0 && error == string.Empty; + return ExecuteSqlLocalDb($"p \"{instanceName}\" -i", out _, out var error) == 0 && error == string.Empty; } /// @@ -197,8 +209,7 @@ namespace Umbraco.Core.Persistence { EnsureAvailable(); if (InstanceExists(instanceName) == false) return false; - string output, error; - return ExecuteSqlLocalDb(string.Format("s \"{0}\"", instanceName), out output, out error) == 0 && error == string.Empty; + return ExecuteSqlLocalDb($"s \"{instanceName}\"", out _, out var error) == 0 && error == string.Empty; } /// @@ -230,7 +241,7 @@ namespace Umbraco.Core.Persistence /// /// Gets the name of the instance. /// - public string InstanceName { get; private set; } + public string InstanceName { get; } /// /// Initializes a new instance of the class. @@ -239,7 +250,7 @@ namespace Umbraco.Core.Persistence public Instance(string instanceName) { InstanceName = instanceName; - _masterCstr = string.Format(@"Server=(localdb)\{0};Integrated Security=True;", instanceName); + _masterCstr = $@"Server=(localdb)\{instanceName};Integrated Security=True;"; } /// @@ -252,7 +263,7 @@ namespace Umbraco.Core.Persistence /// public string GetConnectionString(string databaseName) { - return _masterCstr + string.Format(@"Database={0};", databaseName); + return _masterCstr + $@"Database={databaseName};"; } /// @@ -268,10 +279,9 @@ namespace Umbraco.Core.Persistence /// public string GetAttachedConnectionString(string databaseName, string filesPath) { - string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; - GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out ldfFilename); + GetDatabaseFiles(databaseName, filesPath, out _, out _, out _, out var mdfFilename, out _); - return _masterCstr + string.Format(@"AttachDbFileName='{0}';", mdfFilename); + return _masterCstr + $@"AttachDbFileName='{mdfFilename}';"; } /// @@ -367,8 +377,7 @@ namespace Umbraco.Core.Persistence /// public bool CreateDatabase(string databaseName, string filesPath) { - string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; - GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out ldfFilename); + GetDatabaseFiles(databaseName, filesPath, out var logName, out _, out _, out var mdfFilename, out var ldfFilename); using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) @@ -380,13 +389,10 @@ namespace Umbraco.Core.Persistence // cannot use parameters on CREATE DATABASE // ie "CREATE DATABASE @0 ..." does not work - SetCommand(cmd, string.Format(@" - CREATE DATABASE {0} - ON (NAME=N{1}, FILENAME={2}) - LOG ON (NAME=N{3}, FILENAME={4})", - QuotedName(databaseName), - QuotedName(databaseName, '\''), QuotedName(mdfFilename, '\''), - QuotedName(logName, '\''), QuotedName(ldfFilename, '\''))); + SetCommand(cmd, $@" + CREATE DATABASE {QuotedName(databaseName)} + ON (NAME=N{QuotedName(databaseName, '\'')}, FILENAME={QuotedName(mdfFilename, '\'')}) + LOG ON (NAME=N{QuotedName(logName, '\'')}, FILENAME={QuotedName(ldfFilename, '\'')})"); var unused = cmd.ExecuteNonQuery(); } @@ -608,9 +614,8 @@ namespace Umbraco.Core.Persistence { // cannot use parameters on ALTER DATABASE // ie "ALTER DATABASE @0 ..." does not work - SetCommand(cmd, string.Format(@" - ALTER DATABASE {0} SET SINGLE_USER WITH ROLLBACK IMMEDIATE", - QuotedName(databaseName))); + SetCommand(cmd, $@" + ALTER DATABASE {QuotedName(databaseName)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE"); var unused1 = cmd.ExecuteNonQuery(); } @@ -631,9 +636,8 @@ namespace Umbraco.Core.Persistence // cannot use parameters on DROP DATABASE // ie "DROP DATABASE @0 ..." does not work - SetCommand(cmd, string.Format(@" - DROP DATABASE {0}", - QuotedName(databaseName))); + SetCommand(cmd, $@" + DROP DATABASE {QuotedName(databaseName)}"); var unused2 = cmd.ExecuteNonQuery(); @@ -651,7 +655,7 @@ namespace Umbraco.Core.Persistence private static string GetLogFilename(string mdfFilename) { if (mdfFilename.EndsWith(".mdf") == false) - throw new ArgumentException("Not a valid MDF filename (no .mdf extension).", "mdfFilename"); + throw new ArgumentException("Not a valid MDF filename (no .mdf extension).", nameof(mdfFilename)); return mdfFilename.Substring(0, mdfFilename.Length - ".mdf".Length) + "_log.ldf"; } @@ -664,9 +668,8 @@ namespace Umbraco.Core.Persistence { // cannot use parameters on ALTER DATABASE // ie "ALTER DATABASE @0 ..." does not work - SetCommand(cmd, string.Format(@" - ALTER DATABASE {0} SET SINGLE_USER WITH ROLLBACK IMMEDIATE", - QuotedName(databaseName))); + SetCommand(cmd, $@" + ALTER DATABASE {QuotedName(databaseName)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE"); var unused1 = cmd.ExecuteNonQuery(); @@ -685,19 +688,16 @@ namespace Umbraco.Core.Persistence /// The directory containing database files. private static void AttachDatabase(SqlCommand cmd, string databaseName, string filesPath) { - string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; - GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out ldfFilename); + GetDatabaseFiles(databaseName, filesPath, + out var logName, out _, out _, out var mdfFilename, out var ldfFilename); // cannot use parameters on CREATE DATABASE // ie "CREATE DATABASE @0 ..." does not work - SetCommand(cmd, string.Format(@" - CREATE DATABASE {0} - ON (NAME=N{1}, FILENAME={2}) - LOG ON (NAME=N{3}, FILENAME={4}) - FOR ATTACH", - QuotedName(databaseName), - QuotedName(databaseName, '\''), QuotedName(mdfFilename, '\''), - QuotedName(logName, '\''), QuotedName(ldfFilename, '\''))); + SetCommand(cmd, $@" + CREATE DATABASE {QuotedName(databaseName)} + ON (NAME=N{QuotedName(databaseName, '\'')}, FILENAME={QuotedName(mdfFilename, '\'')}) + LOG ON (NAME=N{QuotedName(logName, '\'')}, FILENAME={QuotedName(ldfFilename, '\'')}) + FOR ATTACH"); var unused = cmd.ExecuteNonQuery(); } @@ -790,9 +790,8 @@ namespace Umbraco.Core.Persistence && (sourceExtension == null && targetExtension == null || sourceExtension == targetExtension); if (nop && delete == false) return; - string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; GetDatabaseFiles(databaseName, filesPath, - out logName, out baseFilename, out baseLogFilename, out mdfFilename, out ldfFilename); + out _, out _, out _, out var mdfFilename, out var ldfFilename); if (sourceExtension != null) { @@ -809,9 +808,8 @@ namespace Umbraco.Core.Persistence else { // copy or copy+delete ie move - string targetLogName, targetBaseFilename, targetLogFilename, targetMdfFilename, targetLdfFilename; GetDatabaseFiles(targetDatabaseName ?? databaseName, targetFilesPath ?? filesPath, - out targetLogName, out targetBaseFilename, out targetLogFilename, out targetMdfFilename, out targetLdfFilename); + out _, out _, out _, out var targetMdfFilename, out var targetLdfFilename); if (targetExtension != null) { @@ -846,9 +844,8 @@ namespace Umbraco.Core.Persistence /// public bool DatabaseFilesExist(string databaseName, string filesPath, string extension = null) { - string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; GetDatabaseFiles(databaseName, filesPath, - out logName, out baseFilename, out baseLogFilename, out mdfFilename, out ldfFilename); + out _, out _, out _, out var mdfFilename, out var ldfFilename); if (extension != null) { @@ -897,16 +894,13 @@ namespace Umbraco.Core.Persistence /// private int ExecuteSqlLocalDb(string args, out string output, out string error) { - var programFiles = Environment.GetEnvironmentVariable("ProgramFiles"); - if (programFiles == null) + if (_exe == null) // should never happen - we should not execute if not available { output = string.Empty; error = "SqlLocalDB.exe not found"; return -1; } - var path = Path.Combine(programFiles, string.Format(@"Microsoft SQL Server\{0}0\Tools\Binn\SqlLocalDB.exe", _version)); - var p = new Process { StartInfo = @@ -914,7 +908,7 @@ namespace Umbraco.Core.Persistence UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, - FileName = path, + FileName = _exe, Arguments = args, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden diff --git a/src/Umbraco.Core/Persistence/PocoDataDataReader.cs b/src/Umbraco.Core/Persistence/PocoDataDataReader.cs index 5b3333ee33..7bfc31f66c 100644 --- a/src/Umbraco.Core/Persistence/PocoDataDataReader.cs +++ b/src/Umbraco.Core/Persistence/PocoDataDataReader.cs @@ -40,7 +40,12 @@ namespace Umbraco.Core.Persistence _tableDefinition = DefinitionFactory.GetTableDefinition(pd.Type, sqlSyntaxProvider); if (_tableDefinition == null) throw new InvalidOperationException("No table definition found for type " + pd.Type); - _readerColumns = pd.Columns.Select(x => x.Value).ToArray(); + // only real columns, exclude result columns + _readerColumns = pd.Columns + .Where(x => x.Value.ResultColumn == false) + .Select(x => x.Value) + .ToArray(); + _sqlSyntaxProvider = sqlSyntaxProvider; _enumerator = dataSource.GetEnumerator(); _columnDefinitions = _tableDefinition.Columns.ToArray(); diff --git a/src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs new file mode 100644 index 0000000000..1d4d2fe531 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using Umbraco.Core.Models; + +namespace Umbraco.Core.Persistence.Repositories +{ + /// + /// Represents a repository for entities. + /// + public interface IAuditEntryRepository : IReadWriteQueryRepository + { + /// + /// Gets a page of entries. + /// + IEnumerable GetPage(long pageIndex, int pageCount, out long records); + + /// + /// Determines whether the repository is available. + /// + /// During an upgrade, the repository may not be available, until the table has been created. + bool IsAvailable(); + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IAuditRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IAuditRepository.cs index f05dcfca91..7c8a82bb85 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IAuditRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IAuditRepository.cs @@ -1,9 +1,37 @@ -using Umbraco.Core.Models; +using System.Collections.Generic; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Umbraco.Core.Persistence.Querying; namespace Umbraco.Core.Persistence.Repositories { - public interface IAuditRepository : IReadRepository, IWriteRepository, IQueryRepository + public interface IAuditRepository : IReadRepository, IWriteRepository, IQueryRepository { void CleanLogs(int maximumAgeOfLogsInMinutes); + + /// + /// Return the audit items as paged result + /// + /// + /// The query coming from the service + /// + /// + /// + /// + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query or the custom filter + /// so we need to do that here + /// + /// + /// A user supplied custom filter + /// + /// + IEnumerable GetPagedResultsByQuery( + IQuery query, + long pageIndex, int pageSize, out long totalRecords, + Direction orderDirection, + AuditType[] auditTypeFilter, + IQuery customFilter); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IConsentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IConsentRepository.cs new file mode 100644 index 0000000000..85cfb52ba3 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IConsentRepository.cs @@ -0,0 +1,15 @@ +using Umbraco.Core.Models; + +namespace Umbraco.Core.Persistence.Repositories +{ + /// + /// Represents a repository for entities. + /// + public interface IConsentRepository : IReadWriteQueryRepository + { + /// + /// Clears the current flag. + /// + void ClearCurrent(string source, string context, string action); + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs index 7b3414e3ef..9c75c051bd 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs @@ -39,5 +39,7 @@ namespace Umbraco.Core.Persistence.Repositories void AssignRoles(int[] memberIds, string[] roleNames); void DissociateRoles(int[] memberIds, string[] roleNames); + + int[] GetMemberIds(string[] names); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs index 0079f0c3e1..c9ed1af558 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs @@ -85,5 +85,11 @@ namespace Umbraco.Core.Persistence.Repositories IProfile GetProfile(string username); IProfile GetProfile(int id); IDictionary GetUserStates(); + + Guid CreateLoginSession(int userId, string requestingIpAddress, bool cleanStaleSessions = true); + bool ValidateLoginSession(int userId, Guid sessionId); + int ClearLoginSessions(int userId); + int ClearLoginSessions(TimeSpan timespan); + void ClearLoginSession(Guid sessionId); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/AuditEntryRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/AuditEntryRepository.cs new file mode 100644 index 0000000000..77759ea2da --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/AuditEntryRepository.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NPoco; +using Umbraco.Core.Cache; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Entities; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Persistence.Factories; +using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.Scoping; + +namespace Umbraco.Core.Persistence.Repositories.Implement +{ + /// + /// Represents the NPoco implementation of . + /// + internal class AuditEntryRepository : NPocoRepositoryBase, IAuditEntryRepository + { + /// + /// Initializes a new instance of the class. + /// + public AuditEntryRepository(IScopeAccessor scopeAccessor, CacheHelper cache, ILogger logger) + : base(scopeAccessor, cache, logger) + { } + + /// + protected override Guid NodeObjectTypeId => throw new NotSupportedException(); + + /// + protected override IAuditEntry PerformGet(int id) + { + var sql = Sql() + .Select() + .From() + .Where(x => x.Id == id); + + var dto = Database.FirstOrDefault(sql); + return dto == null ? null : AuditEntryFactory.BuildEntity(dto); + } + + /// + protected override IEnumerable PerformGetAll(params int[] ids) + { + if (ids.Length == 0) + { + var sql = Sql() + .Select() + .From(); + + return Database.Fetch(sql).Select(AuditEntryFactory.BuildEntity); + } + + var entries = new List(); + + foreach (var group in ids.InGroupsOf(2000)) + { + var sql = Sql() + .Select() + .From() + .WhereIn(x => x.Id, group); + + entries.AddRange(Database.Fetch(sql).Select(AuditEntryFactory.BuildEntity)); + } + + return entries; + } + + /// + protected override IEnumerable PerformGetByQuery(IQuery query) + { + var sqlClause = GetBaseQuery(false); + var translator = new SqlTranslator(sqlClause, query); + var sql = translator.Translate(); + return Database.Fetch(sql).Select(AuditEntryFactory.BuildEntity); + } + + /// + protected override Sql GetBaseQuery(bool isCount) + { + var sql = Sql(); + sql = isCount ? sql.SelectCount() : sql.Select(); + sql = sql.From(); + return sql; + } + + /// + protected override string GetBaseWhereClause() + { + return $"{Constants.DatabaseSchema.Tables.AuditEntry}.id = @id"; + } + + /// + protected override IEnumerable GetDeleteClauses() + { + throw new NotSupportedException("Audit entries cannot be deleted."); + } + + /// + protected override void PersistNewItem(IAuditEntry entity) + { + ((EntityBase) entity).AddingEntity(); + + var dto = AuditEntryFactory.BuildDto(entity); + Database.Insert(dto); + entity.Id = dto.Id; + entity.ResetDirtyProperties(); + } + + /// + protected override void PersistUpdatedItem(IAuditEntry entity) + { + throw new NotSupportedException("Audit entries cannot be updated."); + } + + /// + public IEnumerable GetPage(long pageIndex, int pageCount, out long records) + { + var sql = Sql() + .Select() + .From() + .OrderByDescending(x => x.EventDateUtc); + + var page = Database.Page(pageIndex + 1, pageCount, sql); + records = page.TotalItems; + return page.Items.Select(AuditEntryFactory.BuildEntity); + } + + /// + public bool IsAvailable() + { + var tables = SqlSyntax.GetTablesInSchema(Database).ToArray(); + return tables.InvariantContains(Constants.DatabaseSchema.Tables.AuditEntry); + } + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs index 26f02705e7..fd7161e48f 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs @@ -7,19 +7,20 @@ using Umbraco.Core.Cache; using Umbraco.Core.Composing.CompositionRoots; using Umbraco.Core.Logging; using Umbraco.Core.Models; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Scoping; namespace Umbraco.Core.Persistence.Repositories.Implement { - internal class AuditRepository : NPocoRepositoryBase, IAuditRepository + internal class AuditRepository : NPocoRepositoryBase, IAuditRepository { public AuditRepository(IScopeAccessor scopeAccessor, [Inject(RepositoryCompositionRoot.DisabledCache)] CacheHelper cache, ILogger logger) : base(scopeAccessor, cache, logger) { } - protected override void PersistNewItem(AuditItem entity) + protected override void PersistNewItem(IAuditItem entity) { Database.Insert(new LogDto { @@ -31,8 +32,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement }); } - protected override void PersistUpdatedItem(AuditItem entity) + protected override void PersistUpdatedItem(IAuditItem entity) { + // wtf?! inserting when updating?! Database.Insert(new LogDto { Comment = entity.Comment, @@ -43,27 +45,26 @@ namespace Umbraco.Core.Persistence.Repositories.Implement }); } - protected override AuditItem PerformGet(int id) + protected override IAuditItem PerformGet(int id) { var sql = GetBaseQuery(false); sql.Where(GetBaseWhereClause(), new { Id = id }); var dto = Database.First(sql); - if (dto == null) - return null; - - return new AuditItem(dto.NodeId, dto.Comment, Enum.Parse(dto.Header), dto.UserId); + return dto == null + ? null + : new AuditItem(dto.NodeId, dto.Comment, Enum.Parse(dto.Header), dto.UserId); } - protected override IEnumerable PerformGetAll(params int[] ids) + protected override IEnumerable PerformGetAll(params int[] ids) { throw new NotImplementedException(); } - protected override IEnumerable PerformGetByQuery(IQuery query) + protected override IEnumerable PerformGetByQuery(IQuery query) { var sqlClause = GetBaseQuery(false); - var translator = new SqlTranslator(sqlClause, query); + var translator = new SqlTranslator(sqlClause, query); var sql = translator.Translate(); var dtos = Database.Fetch(sql); @@ -82,6 +83,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement sql .From(); + if (!isCount) + sql.LeftJoin().On((left, right) => left.UserId == right.Id); + return sql; } @@ -108,5 +112,61 @@ namespace Umbraco.Core.Persistence.Repositories.Implement "delete from umbracoLog where datestamp < @oldestPermittedLogEntry and logHeader in ('open','system')", new {oldestPermittedLogEntry = oldestPermittedLogEntry}); } + + /// + /// Return the audit items as paged result + /// + /// + /// The query coming from the service + /// + /// + /// + /// + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query or the custom filter + /// so we need to do that here + /// + /// + /// A user supplied custom filter + /// + /// + public IEnumerable GetPagedResultsByQuery(IQuery query, long pageIndex, int pageSize, + out long totalRecords, Direction orderDirection, + AuditType[] auditTypeFilter, + IQuery customFilter) + { + if (auditTypeFilter == null) auditTypeFilter = Array.Empty(); + + var sql = GetBaseQuery(false); + + var translator = new SqlTranslator(sql, query ?? Query()); + sql = translator.Translate(); + + if (customFilter != null) + foreach (var filterClause in customFilter.GetWhereClauses()) + sql.Where(filterClause.Item1, filterClause.Item2); + + if (auditTypeFilter.Length > 0) + foreach (var type in auditTypeFilter) + sql.Where("(logHeader=@0)", type.ToString()); + + sql = orderDirection == Direction.Ascending + ? sql.OrderBy("Datestamp") + : sql.OrderByDescending("Datestamp"); + + // get page + var page = Database.Page(pageIndex + 1, pageSize, sql); + totalRecords = page.TotalItems; + + var items = page.Items.Select( + dto => new AuditItem(dto.Id, dto.Comment, Enum.Parse(dto.Header), dto.UserId)).ToArray(); + + // map the DateStamp + for (var i = 0; i < items.Length; i++) + items[i].CreateDate = page.Items[i].Datestamp; + + return items; + } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/ConsentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/ConsentRepository.cs new file mode 100644 index 0000000000..3794bf183a --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/ConsentRepository.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using NPoco; +using Umbraco.Core.Cache; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Entities; +using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Persistence.Factories; +using Umbraco.Core.Scoping; + +namespace Umbraco.Core.Persistence.Repositories.Implement +{ + /// + /// Represents the NPoco implementation of . + /// + internal class ConsentRepository : NPocoRepositoryBase, IConsentRepository + { + /// + /// Initializes a new instance of the class. + /// + public ConsentRepository(IScopeAccessor scopeAccessor, CacheHelper cache, ILogger logger) + : base(scopeAccessor, cache, logger) + { } + + /// + protected override Guid NodeObjectTypeId => throw new NotSupportedException(); + + /// + protected override IConsent PerformGet(int id) + { + throw new NotSupportedException(); + } + + /// + protected override IEnumerable PerformGetAll(params int[] ids) + { + throw new NotSupportedException(); + } + + /// + protected override IEnumerable PerformGetByQuery(IQuery query) + { + var sqlClause = Sql().Select().From(); + var translator = new SqlTranslator(sqlClause, query); + var sql = translator.Translate().OrderByDescending(x => x.CreateDate); + return ConsentFactory.BuildEntities(Database.Fetch(sql)); + } + + /// + protected override Sql GetBaseQuery(bool isCount) + { + throw new NotSupportedException(); + } + + /// + protected override string GetBaseWhereClause() + { + throw new NotSupportedException(); + } + + /// + protected override IEnumerable GetDeleteClauses() + { + throw new NotSupportedException(); + } + + /// + protected override void PersistNewItem(IConsent entity) + { + ((EntityBase) entity).AddingEntity(); + + var dto = ConsentFactory.BuildDto(entity); + Database.Insert(dto); + entity.Id = dto.Id; + entity.ResetDirtyProperties(); + } + + /// + protected override void PersistUpdatedItem(IConsent entity) + { + ((EntityBase) entity).UpdatingEntity(); + + var dto = ConsentFactory.BuildDto(entity); + Database.Update(dto); + entity.ResetDirtyProperties(); + + IsolatedCache.ClearCacheItem(RepositoryCacheKeys.GetKey(entity.Id)); + } + + /// + public void ClearCurrent(string source, string context, string action) + { + var sql = Sql() + .Update(u => u.Set(x => x.Current, false)) + .Where(x => x.Source == source && x.Context == context && x.Action == action && x.Current); + Database.Execute(sql); + } + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs index 52d137cb16..a08ecef98d 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs @@ -80,10 +80,10 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override Sql GetBaseQuery(QueryType queryType) { - return GetBaseQuery(queryType, true); + return GetBaseQuery(queryType); } - protected virtual Sql GetBaseQuery(QueryType queryType, bool current) + protected virtual Sql GetBaseQuery(QueryType queryType, bool current = true, bool joinMediaVersion = false) { var sql = SqlContext.Sql(); @@ -108,6 +108,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .InnerJoin().On(left => left.NodeId, right => right.NodeId) .InnerJoin().On(left => left.NodeId, right => right.NodeId); + if (joinMediaVersion) + sql.InnerJoin().On((left, right) => left.Id == right.Id); sql.Where(x => x.NodeObjectType == NodeObjectTypeId); @@ -143,6 +145,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement "DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE childId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Document + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.MediaVersion + " WHERE id IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE versionId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Content + " WHERE nodeId = @id", @@ -157,7 +160,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement public override IEnumerable GetAllVersions(int nodeId) { - var sql = GetBaseQuery(QueryType.Many, false) + var sql = GetBaseQuery(QueryType.Many, current: false) .Where(x => x.NodeId == nodeId) .OrderByDescending(x => x.Current) .AndByDescending(x => x.VersionDate); @@ -188,29 +191,14 @@ namespace Umbraco.Core.Persistence.Repositories.Implement umbracoFileValue = string.Concat(mediaPath.Substring(0, underscoreIndex), mediaPath.Substring(dotIndex)); } - // If the stripped-down url returns null, we try again with the original url. - // Previously, the function would fail on e.g. "my_x_image.jpg" - var nodeId = GetMediaNodeIdByPath(Sql().Where(x => x.VarcharValue == umbracoFileValue)); - if (nodeId < 0) nodeId = GetMediaNodeIdByPath(Sql().Where(x => x.VarcharValue == mediaPath)); + var sql = GetBaseQuery(QueryType.Single, joinMediaVersion: true) + .Where(x => x.Path == umbracoFileValue) + .SelectTop(1); - // If no result so far, try getting from a json value stored in the ntext / nvarchar column - if (nodeId < 0) nodeId = GetMediaNodeIdByPath(Sql().Where("textValue LIKE @0", "%" + umbracoFileValue + "%")); - if (nodeId < 0) nodeId = GetMediaNodeIdByPath(Sql().Where("varcharValue LIKE @0", "%" + umbracoFileValue + "%")); - - return nodeId < 0 ? null : Get(nodeId); - } - - private int GetMediaNodeIdByPath(Sql query) - { - var sql = Sql().Select(x => x.NodeId) - .From() - .InnerJoin().On(left => left.PropertyTypeId, right => right.Id) - .InnerJoin().On((left, right) => left.VersionId == right.Id) - .Where(x => x.Alias == "umbracoFile") - .Append(query); - - var nodeId = Database.Fetch(sql).FirstOrDefault(); - return nodeId ?? -1; + var dto = Database.Fetch(sql).FirstOrDefault(); + return dto == null + ? null + : MapDtoToContent(dto); } protected override void PerformDeleteVersion(int id, int versionId) @@ -249,7 +237,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var sortOrder = GetNewChildSortOrder(entity.ParentId, 0); // persist the node dto - var nodeDto = dto.NodeDto; + var nodeDto = dto.ContentDto.NodeDto; nodeDto.Path = parent.Path; nodeDto.Level = Convert.ToInt16(level); nodeDto.SortOrder = sortOrder; @@ -258,21 +246,13 @@ namespace Umbraco.Core.Persistence.Repositories.Implement // and then either update or insert the node dto var id = GetReservedId(nodeDto.UniqueId); if (id > 0) - { nodeDto.NodeId = id; - nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId); - nodeDto.ValidatePathWithException(); - Database.Update(nodeDto); - } else - { Database.Insert(nodeDto); - // update path, now that we have an id - nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId); - nodeDto.ValidatePathWithException(); - Database.Update(nodeDto); - } + nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId); + nodeDto.ValidatePathWithException(); + Database.Update(nodeDto); // update entity entity.Id = nodeDto.NodeId; @@ -281,17 +261,23 @@ namespace Umbraco.Core.Persistence.Repositories.Implement entity.Level = level; // persist the content dto - dto.NodeId = nodeDto.NodeId; - Database.Insert(dto); + var contentDto = dto.ContentDto; + contentDto.NodeId = nodeDto.NodeId; + Database.Insert(contentDto); // persist the content version dto // assumes a new version id and version date (modified date) has been set - var contentVersionDto = dto.ContentVersionDto; + var contentVersionDto = dto.MediaVersionDto.ContentVersionDto; contentVersionDto.NodeId = nodeDto.NodeId; contentVersionDto.Current = true; Database.Insert(contentVersionDto); media.VersionId = contentVersionDto.Id; + // persist the media version dto + var mediaVersionDto = dto.MediaVersionDto; + mediaVersionDto.Id = media.VersionId; + Database.Insert(mediaVersionDto); + // persist the property data var propertyDataDtos = PropertyFactory.BuildDtos(media.VersionId, 0, entity.Properties, out _); foreach (var propertyDataDto in propertyDataDtos) @@ -333,17 +319,19 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var dto = ContentBaseFactory.BuildDto(entity); // update the node dto - var nodeDto = dto.NodeDto; + var nodeDto = dto.ContentDto.NodeDto; nodeDto.ValidatePathWithException(); Database.Update(nodeDto); // update the content dto - Database.Update(dto); + Database.Update(dto.ContentDto); - // update the content version dto - var contentVersionDto = dto.ContentVersionDto; + // update the content & media version dtos + var contentVersionDto = dto.MediaVersionDto.ContentVersionDto; + var mediaVersionDto = dto.MediaVersionDto; contentVersionDto.Current = true; Database.Update(contentVersionDto); + Database.Update(mediaVersionDto); // replace the property data var deletePropertyDataSql = SqlContext.Sql().Delete().Where(x => x.VersionId == media.VersionId); diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberGroupRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberGroupRepository.cs index 0e7c18597b..f29fef1102 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberGroupRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberGroupRepository.cs @@ -173,20 +173,19 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .Select("un.*") .From("umbracoNode AS un") .InnerJoin("cmsMember2MemberGroup") - .On("un.id = cmsMember2MemberGroup.MemberGroup") - .LeftJoin("(SELECT umbracoNode.id, cmsMember.LoginName FROM umbracoNode INNER JOIN cmsMember ON umbracoNode.id = cmsMember.nodeId) AS member") - .On("member.id = cmsMember2MemberGroup.Member") - .Where("un.nodeObjectType=@objectType", new {objectType = NodeObjectTypeId }) - .Where("member.LoginName=@loginName", new {loginName = username}); + .On("cmsMember2MemberGroup.MemberGroup = un.id") + .InnerJoin("cmsMember") + .On("cmsMember.nodeId = cmsMember2MemberGroup.Member") + .Where("un.nodeObjectType=@objectType", new { objectType = NodeObjectTypeId }) + .Where("cmsMember.LoginName=@loginName", new { loginName = username }); return Database.Fetch(sql) .DistinctBy(dto => dto.NodeId) .Select(x => _modelFactory.BuildEntity(x)); } - public void AssignRoles(string[] usernames, string[] roleNames) + public int[] GetMemberIds(string[] usernames) { - //first get the member ids based on the usernames var memberObjectType = Constants.ObjectTypes.Member; var memberSql = Sql() @@ -196,26 +195,17 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .On(dto => dto.NodeId, dto => dto.NodeId) .Where(x => x.NodeObjectType == memberObjectType) .Where("cmsMember.LoginName in (@usernames)", new { /*usernames =*/ usernames }); - var memberIds = Database.Fetch(memberSql).ToArray(); + return Database.Fetch(memberSql).ToArray(); + } - AssignRolesInternal(memberIds, roleNames); + public void AssignRoles(string[] usernames, string[] roleNames) + { + AssignRolesInternal(GetMemberIds(usernames), roleNames); } public void DissociateRoles(string[] usernames, string[] roleNames) { - //first get the member ids based on the usernames - var memberObjectType = Constants.ObjectTypes.Member; - - var memberSql = Sql() - .Select("umbracoNode.id") - .From() - .InnerJoin() - .On(dto => dto.NodeId, dto => dto.NodeId) - .Where( x => x.NodeObjectType == memberObjectType) - .Where("cmsMember.LoginName in (@usernames)", new { /*usernames =*/ usernames }); - var memberIds = Database.Fetch(memberSql).ToArray(); - - DissociateRolesInternal(memberIds, roleNames); + DissociateRolesInternal(GetMemberIds(usernames), roleNames); } public void AssignRoles(int[] memberIds, string[] roleNames) diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs index 6fef47679b..72f59c0d2a 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs @@ -147,7 +147,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .Select("umbracoNode.*", "cmsContentType.*", "cmsPropertyType.id AS PropertyTypeId", "cmsPropertyType.Alias", "cmsPropertyType.Name", "cmsPropertyType.Description", "cmsPropertyType.mandatory", "cmsPropertyType.UniqueID", "cmsPropertyType.validationRegExp", "cmsPropertyType.dataTypeId", "cmsPropertyType.sortOrder AS PropertyTypeSortOrder", - "cmsPropertyType.propertyTypeGroupId AS PropertyTypesGroupId", "cmsMemberType.memberCanEdit", "cmsMemberType.viewOnProfile", + "cmsPropertyType.propertyTypeGroupId AS PropertyTypesGroupId", + "cmsMemberType.memberCanEdit", "cmsMemberType.viewOnProfile", "cmsMemberType.isSensitive", "uDataType.propertyEditorAlias", "uDataType.dbType", "cmsPropertyTypeGroup.id AS PropertyTypeGroupId", "cmsPropertyTypeGroup.text AS PropertyGroupName", "cmsPropertyTypeGroup.uniqueID AS PropertyGroupUniqueID", "cmsPropertyTypeGroup.sortorder AS PropertyGroupSortOrder", "cmsPropertyTypeGroup.contenttypeNodeId") diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/SimilarNodeName.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/SimilarNodeName.cs index 56a520902c..9f27b6b9e3 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/SimilarNodeName.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/SimilarNodeName.cs @@ -20,6 +20,10 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var name = Name; + // cater nodes with no name. + if (string.IsNullOrWhiteSpace(name)) + return _numPos; + if (name[name.Length - 1] != ')') return _numPos = -1; @@ -106,7 +110,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } } - return uniqueing ? string.Concat(nodeName, " (", uniqueNumber.ToString(), ")") : nodeName; + return uniqueing || string.IsNullOrWhiteSpace(nodeName) + ? string.Concat(nodeName, " (", uniqueNumber.ToString(), ")") + : nodeName; } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs index 9f9cae3288..91466a0d09 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs @@ -298,6 +298,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement entity.Id = id; PersistAllowedSections(entity); + + entity.ResetDirtyProperties(); } protected override void PersistUpdatedItem(IUserGroup entity) @@ -309,6 +311,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Database.Update(userGroupDto); PersistAllowedSections(entity); + + entity.ResetDirtyProperties(); } private void PersistAllowedSections(IUserGroup entity) diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/UserRepository.cs index 9ce7358408..636c9fda69 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/UserRepository.cs @@ -7,6 +7,7 @@ using System.Web.Security; using Newtonsoft.Json; using NPoco; using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Models.Entities; using Umbraco.Core.Models.Membership; @@ -151,14 +152,91 @@ ORDER BY colName"; return new Dictionary { - {UserState.All, result[0].num}, - {UserState.Active, result[1].num}, - {UserState.Disabled, result[2].num}, - {UserState.LockedOut, result[3].num}, - {UserState.Invited, result[4].num} + {UserState.All, (int) result[0].num}, + {UserState.Active, (int) result[1].num}, + {UserState.Disabled, (int) result[2].num}, + {UserState.LockedOut, (int) result[3].num}, + {UserState.Invited, (int) result[4].num} }; } + public Guid CreateLoginSession(int userId, string requestingIpAddress, bool cleanStaleSessions = true) + { + //TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository + //and also business logic models for these objects but that's just so overkill for what we are doing + //and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore + var now = DateTime.UtcNow; + var dto = new UserLoginDto + { + UserId = userId, + IpAddress = requestingIpAddress, + LoggedInUtc = now, + LastValidatedUtc = now, + LoggedOutUtc = null, + SessionId = Guid.NewGuid() + }; + Database.Insert(dto); + + if (cleanStaleSessions) + { + ClearLoginSessions(TimeSpan.FromDays(15)); + } + + return dto.SessionId; + } + + public bool ValidateLoginSession(int userId, Guid sessionId) + { + var found = Database.FirstOrDefault("WHERE sessionId=@sessionId", new {sessionId = sessionId}); + if (found == null || found.UserId != userId || found.LoggedOutUtc.HasValue) + return false; + + //now detect if there's been a timeout + if (DateTime.UtcNow - found.LastValidatedUtc > TimeSpan.FromMinutes(GlobalSettings.TimeOutInMinutes)) + { + //timeout detected, update the record + ClearLoginSession(sessionId); + return false; + } + + //update the validate date + found.LastValidatedUtc = DateTime.UtcNow; + Database.Update(found); + return true; + } + + public int ClearLoginSessions(int userId) + { + //TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository + //and also business logic models for these objects but that's just so overkill for what we are doing + //and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore + var count = Database.ExecuteScalar("SELECT COUNT(*) FROM umbracoUserLogin WHERE userId=@userId", new { userId = userId }); + Database.Execute("DELETE FROM umbracoUserLogin WHERE userId=@userId", new {userId = userId}); + return count; + } + + public int ClearLoginSessions(TimeSpan timespan) + { + //TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository + //and also business logic models for these objects but that's just so overkill for what we are doing + //and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore + + var fromDate = DateTime.UtcNow - timespan; + + var count = Database.ExecuteScalar("SELECT COUNT(*) FROM umbracoUserLogin WHERE lastValidatedUtc=@fromDate", new { fromDate = fromDate }); + Database.Execute("DELETE FROM umbracoUserLogin WHERE lastValidatedUtc=@fromDate", new { fromDate = fromDate }); + return count; + } + + public void ClearLoginSession(Guid sessionId) + { + //TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository + //and also business logic models for these objects but that's just so overkill for what we are doing + //and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore + Database.Execute("UPDATE umbracoUserLogin SET loggedOutUtc=@now WHERE sessionId=@sessionId", + new { now = DateTime.UtcNow, sessionId = sessionId }); + } + protected override IEnumerable PerformGetAll(params int[] ids) { var dtos = ids.Length == 0 @@ -429,7 +507,8 @@ ORDER BY colName"; {"updateDate", "UpdateDate"}, {"avatar", "Avatar"}, {"emailConfirmedDate", "EmailConfirmedDate"}, - {"invitedDate", "InvitedDate"} + {"invitedDate", "InvitedDate"}, + {"tourData", "TourData"} }; // create list of properties that have changed diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index a2854ea1be..9719a5f223 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -373,6 +373,10 @@ + + + + From a2a4edb3be4d732988a4c400ab1f32ff1897531c Mon Sep 17 00:00:00 2001 From: Stephan Date: Thu, 22 Mar 2018 17:41:13 +0100 Subject: [PATCH 07/15] Port v7@2aa0dfb2c5 - WIP --- ...ventHandler.cs => AuditEventsComponent.cs} | 60 +++--- .../Collections/DeepCloneableList.cs | 6 + .../Configuration/GlobalSettings.cs | 2 +- src/Umbraco.Core/Constants-Applications.cs | 2 - .../Models/Entities/EntitySlim.cs | 5 + .../Models/Identity/BackOfficeIdentityUser.cs | 4 + .../ColorPickerConfiguration.cs | 11 ++ .../PropertyEditors/DataValueEditor.cs | 4 + .../ColorPickerValueConverter.cs | 43 +++- .../ValueConverters/GridValueConverter.cs | 4 +- .../MultipleTextStringValueConverter.cs | 4 +- .../PropertyEditors/ValueListConfiguration.cs | 6 +- .../Security/AuthenticationExtensions.cs | 2 +- .../BackOfficeClaimsIdentityFactory.cs | 27 +-- .../BackOfficeCookieAuthenticationProvider.cs | 43 ++-- .../Services/EntityXmlSerializer.cs | 2 + src/Umbraco.Core/Services/IAuditService.cs | 79 +++++++- src/Umbraco.Core/Services/IConsentService.cs | 45 +++++ src/Umbraco.Core/Services/IContentService.cs | 17 +- src/Umbraco.Core/Services/IMemberService.cs | 1 + src/Umbraco.Core/Services/IUserService.cs | 31 ++- src/Umbraco.Core/Services/IdkMap.cs | 22 ++- .../Services/Implement/AuditService.cs | 184 +++++++++++++++++- .../Services/Implement/ConsentService.cs | 81 ++++++++ .../Services/Implement/ContentService.cs | 165 +++++++++++----- .../Services/Implement/ContentTypeService.cs | 11 +- .../ContentTypeServiceBaseOfTItemTService.cs | 2 +- .../Services/Implement/MediaService.cs | 9 +- .../Services/Implement/MemberService.cs | 103 +++++++++- .../Services/Implement/PackagingService.cs | 26 ++- .../Services/Implement/UserService.cs | 72 ++++++- src/Umbraco.Core/Services/ServiceContext.cs | 21 +- src/Umbraco.Core/UdiGetterExtensions.cs | 2 +- src/Umbraco.Core/Umbraco.Core.csproj | 6 +- src/Umbraco.Core/UriExtensions.cs | 3 +- .../ColorPickerConfigurationEditor.cs | 79 +++++++- src/Umbraco.Web/Umbraco.Web.csproj | 1 - 37 files changed, 965 insertions(+), 220 deletions(-) rename src/Umbraco.Core/Auditing/{AuditEventHandler.cs => AuditEventsComponent.cs} (83%) create mode 100644 src/Umbraco.Core/PropertyEditors/ColorPickerConfiguration.cs rename src/{Umbraco.Web => Umbraco.Core}/PropertyEditors/ValueListConfiguration.cs (85%) create mode 100644 src/Umbraco.Core/Services/IConsentService.cs create mode 100644 src/Umbraco.Core/Services/Implement/ConsentService.cs diff --git a/src/Umbraco.Core/Auditing/AuditEventHandler.cs b/src/Umbraco.Core/Auditing/AuditEventsComponent.cs similarity index 83% rename from src/Umbraco.Core/Auditing/AuditEventHandler.cs rename to src/Umbraco.Core/Auditing/AuditEventsComponent.cs index 9b38d5b59e..57457f9241 100644 --- a/src/Umbraco.Core/Auditing/AuditEventHandler.cs +++ b/src/Umbraco.Core/Auditing/AuditEventsComponent.cs @@ -3,19 +3,21 @@ using System.Linq; using System.Text; using System.Threading; using System.Web; +using Umbraco.Core.Components; using Umbraco.Core.Events; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Security; using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; namespace Umbraco.Core.Auditing { - public sealed class AuditEventHandler : ApplicationEventHandler + public sealed class AuditEventsComponent : UmbracoComponentBase, IUmbracoCoreComponent { - private IAuditService _auditServiceInstance; - private IUserService _userServiceInstance; - private IEntityService _entityServiceInstance; + private IAuditService _auditService; + private IUserService _userService; + private IEntityService _entityService; private IUser CurrentPerformingUser { @@ -24,13 +26,13 @@ namespace Umbraco.Core.Auditing var identity = Thread.CurrentPrincipal?.GetUmbracoIdentity(); return identity == null ? new User { Id = 0, Name = "SYSTEM", Email = "" } - : _userServiceInstance.GetUserById(Convert.ToInt32(identity.Id)); + : _userService.GetUserById(Convert.ToInt32(identity.Id)); } } private IUser GetPerformingUser(int userId) { - var found = userId >= 0 ? _userServiceInstance.GetUserById(userId) : null; + var found = userId >= 0 ? _userService.GetUserById(userId) : null; return found ?? new User {Id = 0, Name = "SYSTEM", Email = ""}; } @@ -45,11 +47,11 @@ namespace Umbraco.Core.Auditing } } - protected override void ApplicationStarted(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext) + public void Initialize(IAuditService auditService, IUserService userService, IEntityService entityService) { - _auditServiceInstance = applicationContext.Services.AuditService; - _userServiceInstance = applicationContext.Services.UserService; - _entityServiceInstance = applicationContext.Services.EntityService; + _auditService = auditService; + _userService = userService; + _entityService = entityService; //BackOfficeUserManager.AccountLocked += ; //BackOfficeUserManager.AccountUnlocked += ; @@ -63,7 +65,7 @@ namespace Umbraco.Core.Auditing BackOfficeUserManager.PasswordReset += OnPasswordReset; //BackOfficeUserManager.ResetAccessFailedCount += ; - UserService.SavedUserGroup2 += OnSavedUserGroupWithUsers; + UserService.SavedUserGroup += OnSavedUserGroupWithUsers; UserService.SavedUser += OnSavedUser; UserService.DeletedUser += OnDeletedUser; @@ -94,7 +96,7 @@ namespace Umbraco.Core.Auditing foreach (var id in args.MemberIds) { members.TryGetValue(id, out var member); - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, -1, $"Member {id} \"{member?.Name ?? "(unknown)"}\" {FormatEmail(member)}", "umbraco/member/roles/removed", $"roles modified, removed {roles}"); @@ -109,7 +111,7 @@ namespace Umbraco.Core.Auditing foreach (var id in args.MemberIds) { members.TryGetValue(id, out var member); - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, -1, $"Member {id} \"{member?.Name ?? "(unknown)"}\" {FormatEmail(member)}", "umbraco/member/roles/assigned", $"roles modified, assigned {roles}"); @@ -121,7 +123,7 @@ namespace Umbraco.Core.Auditing var performingUser = CurrentPerformingUser; var member = exportedMemberEventArgs.Member; - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, -1, $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", "umbraco/member/exported", "exported member data"); @@ -134,7 +136,7 @@ namespace Umbraco.Core.Auditing { var group = groupWithUser.UserGroup; - var dp = string.Join(", ", ((UserGroup)group).GetPreviouslyDirtyProperties()); + var dp = string.Join(", ", ((UserGroup)group).GetWereDirtyProperties()); var sections = ((UserGroup)group).WasPropertyDirty("AllowedSections") ? string.Join(", ", group.AllowedSections) : null; @@ -153,7 +155,7 @@ namespace Umbraco.Core.Auditing sb.Append($"default perms: {perms}"); } - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, -1, $"User Group {group.Id} \"{group.Name}\" ({group.Alias})", "umbraco/user-group/save", $"{sb}"); @@ -162,7 +164,7 @@ namespace Umbraco.Core.Auditing foreach (var user in groupWithUser.RemovedUsers) { - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, user.Id, $"User \"{user.Name}\" {FormatEmail(user)}", "umbraco/user-group/save", $"Removed user \"{user.Name}\" {FormatEmail(user)} from group {group.Id} \"{group.Name}\" ({group.Alias})"); @@ -170,7 +172,7 @@ namespace Umbraco.Core.Auditing foreach (var user in groupWithUser.AddedUsers) { - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, user.Id, $"User \"{user.Name}\" {FormatEmail(user)}", "umbraco/user-group/save", $"Added user \"{user.Name}\" {FormatEmail(user)} to group {group.Id} \"{group.Name}\" ({group.Alias})"); @@ -186,9 +188,9 @@ namespace Umbraco.Core.Auditing { var group = sender.GetUserGroupById(perm.UserGroupId); var assigned = string.Join(", ", perm.AssignedPermissions); - var entity = _entityServiceInstance.Get(perm.EntityId); + var entity = _entityService.Get(perm.EntityId); - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, -1, $"User Group {group.Id} \"{group.Name}\" ({group.Alias})", "umbraco/user-group/permissions-change", $"assigning {(string.IsNullOrWhiteSpace(assigned) ? "(nothing)" : assigned)} on id:{perm.EntityId} \"{entity.Name}\""); @@ -201,9 +203,9 @@ namespace Umbraco.Core.Auditing var members = saveEventArgs.SavedEntities; foreach (var member in members) { - var dp = string.Join(", ", ((Member) member).GetPreviouslyDirtyProperties()); + var dp = string.Join(", ", ((Member) member).GetWereDirtyProperties()); - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, -1, $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", "umbraco/member/save", $"updating {(string.IsNullOrWhiteSpace(dp) ? "(nothing)" : dp)}"); @@ -216,7 +218,7 @@ namespace Umbraco.Core.Auditing var members = deleteEventArgs.DeletedEntities; foreach (var member in members) { - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, -1, $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", "umbraco/member/delete", $"delete member id:{member.Id} \"{member.Name}\" {FormatEmail(member)}"); @@ -233,9 +235,9 @@ namespace Umbraco.Core.Auditing ? string.Join(", ", affectedUser.Groups.Select(x => x.Alias)) : null; - var dp = string.Join(", ", ((User)affectedUser).GetPreviouslyDirtyProperties()); + var dp = string.Join(", ", ((User)affectedUser).GetWereDirtyProperties()); - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, affectedUser.Id, $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}", "umbraco/user/save", $"updating {(string.IsNullOrWhiteSpace(dp) ? "(nothing)" : dp)}{(groups == null ? "" : "; groups assigned: " + groups)}"); @@ -247,7 +249,7 @@ namespace Umbraco.Core.Auditing var performingUser = CurrentPerformingUser; var affectedUsers = deleteEventArgs.DeletedEntities; foreach (var affectedUser in affectedUsers) - _auditServiceInstance.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, + _auditService.Write(performingUser.Id, $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", PerformingIp, DateTime.UtcNow, affectedUser.Id, $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}", "umbraco/user/delete", "delete user"); @@ -313,7 +315,7 @@ namespace Umbraco.Core.Auditing private void WriteAudit(int performingId, int affectedId, string ipAddress, string eventType, string eventDetails, string affectedDetails = null) { - var performingUser = _userServiceInstance.GetUserById(performingId); + var performingUser = _userService.GetUserById(performingId); var performingDetails = performingUser == null ? $"User UNKNOWN:{performingId}" @@ -335,13 +337,13 @@ namespace Umbraco.Core.Auditing { if (affectedDetails == null) { - var affectedUser = _userServiceInstance.GetUserById(affectedId); + var affectedUser = _userService.GetUserById(affectedId); affectedDetails = affectedUser == null ? $"User UNKNOWN:{affectedId}" : $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}"; } - _auditServiceInstance.Write(performingId, performingDetails, + _auditService.Write(performingId, performingDetails, ipAddress, DateTime.UtcNow, affectedId, affectedDetails, diff --git a/src/Umbraco.Core/Collections/DeepCloneableList.cs b/src/Umbraco.Core/Collections/DeepCloneableList.cs index 4853f89560..48d35efde9 100644 --- a/src/Umbraco.Core/Collections/DeepCloneableList.cs +++ b/src/Umbraco.Core/Collections/DeepCloneableList.cs @@ -136,5 +136,11 @@ namespace Umbraco.Core.Collections dc.ResetDirtyProperties(rememberDirty); } } + + /// Always return an empty enumerable, the list has no properties that can be dirty. + public IEnumerable GetWereDirtyProperties() + { + return Enumerable.Empty(); + } } } diff --git a/src/Umbraco.Core/Configuration/GlobalSettings.cs b/src/Umbraco.Core/Configuration/GlobalSettings.cs index 0ed7289b30..33b8443d11 100644 --- a/src/Umbraco.Core/Configuration/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/GlobalSettings.cs @@ -483,7 +483,7 @@ namespace Umbraco.Core.Configuration { get { - var setting = ConfigurationManager.AppSettings.ContainsKey("umbracoLocalTempStorage"); + var setting = ConfigurationManager.AppSettings["umbracoLocalTempStorage"]; if (!string.IsNullOrWhiteSpace(setting)) return Enum.Parse(setting); diff --git a/src/Umbraco.Core/Constants-Applications.cs b/src/Umbraco.Core/Constants-Applications.cs index d401eaee88..4c859469fd 100644 --- a/src/Umbraco.Core/Constants-Applications.cs +++ b/src/Umbraco.Core/Constants-Applications.cs @@ -126,8 +126,6 @@ public const string Languages = "languages"; - public const string Macros = "macros"; - /// /// alias for the user types tree. /// diff --git a/src/Umbraco.Core/Models/Entities/EntitySlim.cs b/src/Umbraco.Core/Models/Entities/EntitySlim.cs index fbef1fafbd..163879bbe0 100644 --- a/src/Umbraco.Core/Models/Entities/EntitySlim.cs +++ b/src/Umbraco.Core/Models/Entities/EntitySlim.cs @@ -215,6 +215,11 @@ namespace Umbraco.Core.Models.Entities throw new WontImplementException(); } + public IEnumerable GetWereDirtyProperties() + { + throw new WontImplementException(); + } + #endregion } } diff --git a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs index eab9e21013..bd4905729d 100644 --- a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs @@ -390,6 +390,10 @@ namespace Umbraco.Core.Models.Identity _beingDirty.ResetDirtyProperties(rememberDirty); } + /// + public IEnumerable GetWereDirtyProperties() + => _beingDirty.GetWereDirtyProperties(); + /// /// Disables change tracking. /// diff --git a/src/Umbraco.Core/PropertyEditors/ColorPickerConfiguration.cs b/src/Umbraco.Core/PropertyEditors/ColorPickerConfiguration.cs new file mode 100644 index 0000000000..b49cdf4ae1 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ColorPickerConfiguration.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Core.PropertyEditors +{ + /// + /// Represents the configuration for the color picker value editor. + /// + public class ColorPickerConfiguration : ValueListConfiguration + { + [ConfigurationField("useLabel", "Include labels?", "boolean", Description = "Stores colors as a Json object containing both the color hex string and label, rather than just the hex string.")] + public bool UseLabel { get; set; } + } +} diff --git a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs index 6ae55d94cb..60912edad0 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using System.Xml.Linq; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Core.Composing; using Umbraco.Core.IO; using Umbraco.Core.Logging; @@ -179,6 +180,9 @@ namespace Umbraco.Core.PropertyEditors /// internal Attempt TryConvertValueToCrlType(object value) { + if (value is JValue) + value = value.ToString(); + //this is a custom check to avoid any errors, if it's a string and it's empty just make it null if (value is string s && string.IsNullOrWhiteSpace(s)) value = null; diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ColorPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ColorPickerValueConverter.cs index 9e0ec37f5b..9f260fc973 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/ColorPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ColorPickerValueConverter.cs @@ -1,4 +1,6 @@ using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Core.Models.PublishedContent; namespace Umbraco.Core.PropertyEditors.ValueConverters @@ -10,15 +12,50 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.ColorPicker); public override Type GetPropertyValueType(PublishedPropertyType propertyType) - => typeof (string); + => UseLabel(propertyType) ? typeof(PickedColor) : typeof(string); public override PropertyCacheLevel GetPropertyCacheLevel(PublishedPropertyType propertyType) => PropertyCacheLevel.Element; public override object ConvertSourceToIntermediate(IPublishedElement owner, PublishedPropertyType propertyType, object source, bool preview) { - // make sure it's a string - return source?.ToString() ?? string.Empty; + var useLabel = UseLabel(propertyType); + + if (source == null) return useLabel ? null : string.Empty; + + var ssource = source.ToString(); + if (ssource.DetectIsJson()) + { + try + { + var jo = JsonConvert.DeserializeObject(ssource); + if (useLabel) return new PickedColor(jo["value"].ToString(), jo["label"].ToString()); + return jo["value"].ToString(); + } + catch { /* not json finally */ } + } + + if (useLabel) return new PickedColor(ssource, ssource); + return ssource; + } + + private bool UseLabel(PublishedPropertyType propertyType) + { + return ConfigurationEditor.ConfigurationAs(propertyType.DataType.Configuration).UseLabel; + } + + public class PickedColor + { + public PickedColor(string color, string label) + { + Color = color; + Label = label; + } + + public string Color { get; } + public string Label { get; } + + public override string ToString() => Color; } } } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/GridValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/GridValueConverter.cs index 5efd40778b..3117fc0b54 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/GridValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/GridValueConverter.cs @@ -51,8 +51,8 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters var gridConfig = UmbracoConfig.For.GridConfig( Current.ProfilingLogger.Logger, Current.ApplicationCache.RuntimeCache, - new DirectoryInfo(HttpContext.Current.Server.MapPath(SystemDirectories.AppPlugins)), - new DirectoryInfo(HttpContext.Current.Server.MapPath(SystemDirectories.Config)), + new DirectoryInfo(IOHelper.MapPath(SystemDirectories.AppPlugins)), + new DirectoryInfo(IOHelper.MapPath(SystemDirectories.Config)), HttpContext.Current.IsDebuggingEnabled); var sections = GetArray(obj, "sections"); diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultipleTextStringValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultipleTextStringValueConverter.cs index db199eed05..b91383715a 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultipleTextStringValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultipleTextStringValueConverter.cs @@ -18,6 +18,8 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters public override PropertyCacheLevel GetPropertyCacheLevel(PublishedPropertyType propertyType) => PropertyCacheLevel.Element; + private static readonly string[] NewLineDelimiters = { "\r\n", "\r", "\n" }; + public override object ConvertSourceToIntermediate(IPublishedElement owner, PublishedPropertyType propertyType, object source, bool preview) { // data is (both in database and xml): @@ -52,7 +54,7 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters // fall back on normal behaviour return values.Any() == false - ? sourceString.Split(new string[] { Environment.NewLine }, StringSplitOptions.None) + ? sourceString.Split(NewLineDelimiters, StringSplitOptions.None) : values.ToArray(); } diff --git a/src/Umbraco.Web/PropertyEditors/ValueListConfiguration.cs b/src/Umbraco.Core/PropertyEditors/ValueListConfiguration.cs similarity index 85% rename from src/Umbraco.Web/PropertyEditors/ValueListConfiguration.cs rename to src/Umbraco.Core/PropertyEditors/ValueListConfiguration.cs index 30d2aa77cd..e40cdab646 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueListConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueListConfiguration.cs @@ -1,12 +1,12 @@ using System.Collections.Generic; using Newtonsoft.Json; -namespace Umbraco.Web.PropertyEditors +namespace Umbraco.Core.PropertyEditors { /// /// Represents the ValueList editor configuration. /// - class ValueListConfiguration + public class ValueListConfiguration { [JsonProperty("items")] public List Items { get; set; } = new List(); @@ -20,4 +20,4 @@ namespace Umbraco.Web.PropertyEditors public string Value { get; set; } } } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/Security/AuthenticationExtensions.cs b/src/Umbraco.Core/Security/AuthenticationExtensions.cs index eff8b1f958..17bb32675b 100644 --- a/src/Umbraco.Core/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Core/Security/AuthenticationExtensions.cs @@ -324,7 +324,7 @@ namespace Umbraco.Core.Security Guid guidSession; if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) { - ApplicationContext.Current.Services.UserService.ClearLoginSession(guidSession); + Current.Services.UserService.ClearLoginSession(guidSession); } } } diff --git a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs index 4df824544c..cec1ee6bcb 100644 --- a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs +++ b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs @@ -1,30 +1,16 @@ -using System; -using System.Linq; +using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using System.Web; using Microsoft.AspNet.Identity; using Umbraco.Core.Models.Identity; -using Umbraco.Core.Services; namespace Umbraco.Core.Security { public class BackOfficeClaimsIdentityFactory : ClaimsIdentityFactory where T: BackOfficeIdentityUser { - private readonly ApplicationContext _appCtx; - - [Obsolete("Use the overload specifying all dependencies instead")] public BackOfficeClaimsIdentityFactory() - :this(ApplicationContext.Current) { - } - - public BackOfficeClaimsIdentityFactory(ApplicationContext appCtx) - { - if (appCtx == null) throw new ArgumentNullException("appCtx"); - _appCtx = appCtx; - SecurityStampClaimType = Constants.Security.SessionIdClaimType; UserNameClaimType = ClaimTypes.Name; } @@ -58,14 +44,5 @@ namespace Umbraco.Core.Security } public class BackOfficeClaimsIdentityFactory : BackOfficeClaimsIdentityFactory - { - [Obsolete("Use the overload specifying all dependencies instead")] - public BackOfficeClaimsIdentityFactory() - { - } - - public BackOfficeClaimsIdentityFactory(ApplicationContext appCtx) : base(appCtx) - { - } - } + { } } diff --git a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs index 58543f106c..686cefcdf8 100644 --- a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs @@ -1,52 +1,37 @@ using System; using System.Collections.Concurrent; -using System.ComponentModel; using System.Globalization; -using System.Net.Http.Headers; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; -using Semver; using Umbraco.Core.Configuration; -using Umbraco.Core.Models.Identity; +using Umbraco.Core.Services; namespace Umbraco.Core.Security { public class BackOfficeCookieAuthenticationProvider : CookieAuthenticationProvider { - private readonly ApplicationContext _appCtx; + private readonly IUserService _userService; + private readonly IRuntimeState _runtimeState; - [Obsolete("Use the ctor specifying all dependencies")] - [EditorBrowsable(EditorBrowsableState.Never)] - public BackOfficeCookieAuthenticationProvider() - : this(ApplicationContext.Current) + public BackOfficeCookieAuthenticationProvider(IUserService userService, IRuntimeState runtimeState) { + _userService = userService; + _runtimeState = runtimeState; } - public BackOfficeCookieAuthenticationProvider(ApplicationContext appCtx) - { - if (appCtx == null) throw new ArgumentNullException("appCtx"); - _appCtx = appCtx; - } - - private static readonly SemVersion MinUmbracoVersionSupportingLoginSessions = new SemVersion(7, 8); - public override void ResponseSignIn(CookieResponseSignInContext context) { - var backOfficeIdentity = context.Identity as UmbracoBackOfficeIdentity; - if (backOfficeIdentity != null) + if (context.Identity is UmbracoBackOfficeIdentity backOfficeIdentity) { //generate a session id and assign it //create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one - //NOTE - special check because when we are upgrading to 7.8 we cannot create a session since the db isn't ready and we'll get exceptions - var canAcquireSession = _appCtx.IsUpgrading == false || _appCtx.CurrentVersion() >= MinUmbracoVersionSupportingLoginSessions; - - var session = canAcquireSession - ? _appCtx.Services.UserService.CreateLoginSession((int)backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) + var session = _runtimeState.Level == RuntimeLevel.Run + ? _userService.CreateLoginSession((int)backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) : Guid.NewGuid(); backOfficeIdentity.UserData.SessionId = session.ToString(); @@ -58,15 +43,13 @@ namespace Umbraco.Core.Security public override void ResponseSignOut(CookieResponseSignOutContext context) { //Clear the user's session on sign out - if (context != null && context.OwinContext != null && context.OwinContext.Authentication != null - && context.OwinContext.Authentication.User != null && context.OwinContext.Authentication.User.Identity != null) + if (context?.OwinContext?.Authentication?.User?.Identity != null) { var claimsIdentity = context.OwinContext.Authentication.User.Identity as ClaimsIdentity; var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); - Guid guidSession; - if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out var guidSession)) { - _appCtx.Services.UserService.ClearLoginSession(guidSession); + _userService.ClearLoginSession(guidSession); } } @@ -117,7 +100,7 @@ namespace Umbraco.Core.Security /// protected virtual async Task EnsureValidSessionId(CookieValidateIdentityContext context) { - if (_appCtx.IsConfigured && _appCtx.IsUpgrading == false) + if (_runtimeState.Level == RuntimeLevel.Run) await SessionIdValidator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context); } diff --git a/src/Umbraco.Core/Services/EntityXmlSerializer.cs b/src/Umbraco.Core/Services/EntityXmlSerializer.cs index f39c06524d..72838c3d55 100644 --- a/src/Umbraco.Core/Services/EntityXmlSerializer.cs +++ b/src/Umbraco.Core/Services/EntityXmlSerializer.cs @@ -54,6 +54,8 @@ namespace Umbraco.Core.Services xml.Add(new XAttribute("template", content.Template?.Id.ToString(CultureInfo.InvariantCulture) ?? "0")); + xml.Add(new XAttribute("isPublished", content.Published)); + if (withDescendants) { var descendants = contentService.GetDescendants(content).ToArray(); diff --git a/src/Umbraco.Core/Services/IAuditService.cs b/src/Umbraco.Core/Services/IAuditService.cs index 19ad180df3..13d84f802e 100644 --- a/src/Umbraco.Core/Services/IAuditService.cs +++ b/src/Umbraco.Core/Services/IAuditService.cs @@ -1,15 +1,88 @@ using System; using System.Collections.Generic; +using System.Linq; using Umbraco.Core.Models; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Umbraco.Core.Persistence.Querying; namespace Umbraco.Core.Services { + /// + /// Represents a service for handling audit. + /// public interface IAuditService : IService { void Add(AuditType type, string comment, int userId, int objectId); - IEnumerable GetLogs(int objectId); - IEnumerable GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null); - IEnumerable GetLogs(AuditType type, DateTime? sinceDate = null); + + IEnumerable GetLogs(int objectId); + IEnumerable GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null); + IEnumerable GetLogs(AuditType type, DateTime? sinceDate = null); void CleanLogs(int maximumAgeOfLogsInMinutes); + + /// + /// Returns paged items in the audit trail for a given entity + /// + /// + /// + /// + /// + /// + /// By default this will always be ordered descending (newest first) + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query or the custom filter + /// so we need to do that here + /// + /// + /// Optional filter to be applied + /// + /// + IEnumerable GetPagedItemsByEntity(int entityId, long pageIndex, int pageSize, out long totalRecords, + Direction orderDirection = Direction.Descending, + AuditType[] auditTypeFilter = null, + IQuery customFilter = null); + + /// + /// Returns paged items in the audit trail for a given user + /// + /// + /// + /// + /// + /// + /// By default this will always be ordered descending (newest first) + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query or the custom filter + /// so we need to do that here + /// + /// + /// Optional filter to be applied + /// + /// + IEnumerable GetPagedItemsByUser(int userId, long pageIndex, int pageSize, out long totalRecords, + Direction orderDirection = Direction.Descending, + AuditType[] auditTypeFilter = null, + IQuery customFilter = null); + + /// + /// Writes an audit entry for an audited event. + /// + /// The identifier of the user triggering the audited event. + /// Free-form details about the user triggering the audited event. + /// The IP address or the request triggering the audited event. + /// The date and time of the audited event. + /// The identifier of the user affected by the audited event. + /// Free-form details about the entity affected by the audited event. + /// + /// The type of the audited event - must contain only alphanumeric chars and hyphens with forward slashes separating categories. + /// + /// The eventType will generally be formatted like: {application}/{entity-type}/{category}/{sub-category} + /// Example: umbraco/user/sign-in/failed + /// + /// + /// Free-form details about the audited event. + IAuditEntry Write(int performingUserId, string perfomingDetails, string performingIp, DateTime eventDateUtc, int affectedUserId, string affectedDetails, string eventType, string eventDetails); + } } diff --git a/src/Umbraco.Core/Services/IConsentService.cs b/src/Umbraco.Core/Services/IConsentService.cs new file mode 100644 index 0000000000..fdcf18bc74 --- /dev/null +++ b/src/Umbraco.Core/Services/IConsentService.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Umbraco.Core.Models; + +namespace Umbraco.Core.Services +{ + /// + /// A service for handling lawful data processing requirements + /// + /// + /// Consent can be given or revoked or changed via the method, which + /// creates a new entity to track the consent. Revoking a consent is performed by + /// registering a revoked consent. + /// A consent can be revoked, by registering a revoked consent, but cannot be deleted. + /// Getter methods return the current state of a consent, i.e. the latest + /// entity that was created. + /// + public interface IConsentService : IService + { + /// + /// Registers consent. + /// + /// The source, i.e. whoever is consenting. + /// + /// + /// The state of the consent. + /// Additional free text. + /// The corresponding consent entity. + IConsent RegisterConsent(string source, string context, string action, ConsentState state, string comment = null); + + /// + /// Retrieves consents. + /// + /// The optional source. + /// The optional context. + /// The optional action. + /// Determines whether is a start pattern. + /// Determines whether is a start pattern. + /// Determines whether is a start pattern. + /// Determines whether to include the history of consents. + /// Consents matching the paramters. + IEnumerable LookupConsent(string source = null, string context = null, string action = null, + bool sourceStartsWith = false, bool contextStartsWith = false, bool actionStartsWith = false, + bool includeHistory = false); + } +} diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index ebc063fbe7..b08a7ed55d 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -43,7 +43,17 @@ namespace Umbraco.Core.Services /// Creates a new content item from a blueprint. /// IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = 0); - + + /// + /// Deletes blueprints for a content type. + /// + void DeleteBlueprintsOfType(int contentTypeId, int userId = 0); + + /// + /// Deletes blueprints for content types. + /// + void DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, int userId = 0); + #endregion #region Get, Count Documents @@ -326,6 +336,11 @@ namespace Umbraco.Core.Services /// bool Sort(IEnumerable items, int userId = 0, bool raiseEvents = true); + /// + /// Sorts documents. + /// + bool Sort(IEnumerable ids, int userId = 0, bool raiseEvents = true); + #endregion #region Publish Document diff --git a/src/Umbraco.Core/Services/IMemberService.cs b/src/Umbraco.Core/Services/IMemberService.cs index a9394126f8..ffe87451c3 100644 --- a/src/Umbraco.Core/Services/IMemberService.cs +++ b/src/Umbraco.Core/Services/IMemberService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Net.Http; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence.DatabaseModelDefinitions; diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 8c22cffff7..543931196f 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; @@ -12,6 +13,34 @@ namespace Umbraco.Core.Services /// public interface IUserService : IMembershipUserService { + /// + /// Creates a database entry for starting a new login session for a user + /// + /// + /// + /// + Guid CreateLoginSession(int userId, string requestingIpAddress); + + /// + /// Validates that a user login session is valid/current and hasn't been closed + /// + /// + /// + /// + bool ValidateLoginSession(int userId, Guid sessionId); + + /// + /// Removes the session's validity + /// + /// + void ClearLoginSession(Guid sessionId); + + /// + /// Removes all valid sessions for the user + /// + /// + int ClearLoginSessions(int userId); + /// /// This is basically facets of UserStates key = state, value = count /// diff --git a/src/Umbraco.Core/Services/IdkMap.cs b/src/Umbraco.Core/Services/IdkMap.cs index a6a7cf1965..0332e88399 100644 --- a/src/Umbraco.Core/Services/IdkMap.cs +++ b/src/Umbraco.Core/Services/IdkMap.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Threading; using Umbraco.Core.Models; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Scoping; namespace Umbraco.Core.Services @@ -37,8 +39,13 @@ namespace Umbraco.Core.Services int? val; using (var scope = _scopeProvider.CreateScope()) { - val = scope.Database.ExecuteScalar("SELECT id FROM umbracoNode WHERE uniqueId=@id AND nodeObjectType=@nodeObjectType", - new { id = key, nodeObjectType = GetNodeObjectTypeGuid(umbracoObjectType) }); + var sql = scope.Database.SqlContext.Sql() + .Select(x => x.NodeId).From().Where(x => x.UniqueId == key); + + if (umbracoObjectType != UmbracoObjectTypes.Unknown) // if unknow, don't include in query + sql = sql.Where(x => x.NodeObjectType == GetNodeObjectTypeGuid(umbracoObjectType) || x.NodeObjectType == Constants.ObjectTypes.IdReservation); // fixme TEST the OR here! + + val = scope.Database.ExecuteScalar(sql); scope.Complete(); } @@ -89,8 +96,13 @@ namespace Umbraco.Core.Services Guid? val; using (var scope = _scopeProvider.CreateScope()) { - val = scope.Database.ExecuteScalar("SELECT uniqueId FROM umbracoNode WHERE id=@id AND nodeObjectType=@nodeObjectType", - new { id, nodeObjectType = GetNodeObjectTypeGuid(umbracoObjectType) }); + var sql = scope.Database.SqlContext.Sql() + .Select(x => x.UniqueId).From().Where(x => x.NodeId == id); + + if (umbracoObjectType != UmbracoObjectTypes.Unknown) // if unknow, don't include in query + sql = sql.Where(x => x.NodeObjectType == GetNodeObjectTypeGuid(umbracoObjectType) || x.NodeObjectType == Constants.ObjectTypes.IdReservation); // fixme TEST the OR here! + + val = scope.Database.ExecuteScalar(sql); scope.Complete(); } @@ -104,7 +116,7 @@ namespace Umbraco.Core.Services { _locker.EnterWriteLock(); _id2Key[id] = new TypedId(val.Value, umbracoObjectType); - _key2Id[val.Value] = new TypedId(); + _key2Id[val.Value] = new TypedId(id, umbracoObjectType); } finally { diff --git a/src/Umbraco.Core/Services/Implement/AuditService.cs b/src/Umbraco.Core/Services/Implement/AuditService.cs index 0e04121bd6..bcd19a72ac 100644 --- a/src/Umbraco.Core/Services/Implement/AuditService.cs +++ b/src/Umbraco.Core/Services/Implement/AuditService.cs @@ -1,8 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using Umbraco.Core.Events; using Umbraco.Core.Logging; using Umbraco.Core.Models; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.Repositories; using Umbraco.Core.Scoping; @@ -10,13 +14,17 @@ namespace Umbraco.Core.Services.Implement { public sealed class AuditService : ScopeRepositoryService, IAuditService { + private readonly Lazy _isAvailable; private readonly IAuditRepository _auditRepository; + private readonly IAuditEntryRepository _auditEntryRepository; public AuditService(IScopeProvider provider, ILogger logger, IEventMessagesFactory eventMessagesFactory, - IAuditRepository auditRepository) + IAuditRepository auditRepository, IAuditEntryRepository auditEntryRepository) : base(provider, logger, eventMessagesFactory) { _auditRepository = auditRepository; + _auditEntryRepository = auditEntryRepository; + _isAvailable = new Lazy(DetermineIsAvailable); } public void Add(AuditType type, string comment, int userId, int objectId) @@ -28,35 +36,35 @@ namespace Umbraco.Core.Services.Implement } } - public IEnumerable GetLogs(int objectId) + public IEnumerable GetLogs(int objectId) { using (var scope = ScopeProvider.CreateScope()) { - var result = _auditRepository.Get(Query().Where(x => x.Id == objectId)); + var result = _auditRepository.Get(Query().Where(x => x.Id == objectId)); scope.Complete(); return result; } } - public IEnumerable GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null) + public IEnumerable GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null) { using (var scope = ScopeProvider.CreateScope()) { var result = sinceDate.HasValue == false - ? _auditRepository.Get(Query().Where(x => x.UserId == userId && x.AuditType == type)) - : _auditRepository.Get(Query().Where(x => x.UserId == userId && x.AuditType == type && x.CreateDate >= sinceDate.Value)); + ? _auditRepository.Get(Query().Where(x => x.UserId == userId && x.AuditType == type)) + : _auditRepository.Get(Query().Where(x => x.UserId == userId && x.AuditType == type && x.CreateDate >= sinceDate.Value)); scope.Complete(); return result; } } - public IEnumerable GetLogs(AuditType type, DateTime? sinceDate = null) + public IEnumerable GetLogs(AuditType type, DateTime? sinceDate = null) { using (var scope = ScopeProvider.CreateScope()) { var result = sinceDate.HasValue == false - ? _auditRepository.Get(Query().Where(x => x.AuditType == type)) - : _auditRepository.Get(Query().Where(x => x.AuditType == type && x.CreateDate >= sinceDate.Value)); + ? _auditRepository.Get(Query().Where(x => x.AuditType == type)) + : _auditRepository.Get(Query().Where(x => x.AuditType == type && x.CreateDate >= sinceDate.Value)); scope.Complete(); return result; } @@ -70,5 +78,163 @@ namespace Umbraco.Core.Services.Implement scope.Complete(); } } + + /// + /// Returns paged items in the audit trail for a given entity + /// + /// + /// + /// + /// + /// + /// By default this will always be ordered descending (newest first) + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query or the custom filter + /// so we need to do that here + /// + /// + /// Optional filter to be applied + /// + /// + public IEnumerable GetPagedItemsByEntity(int entityId, long pageIndex, int pageSize, out long totalRecords, + Direction orderDirection = Direction.Descending, + AuditType[] auditTypeFilter = null, + IQuery customFilter = null) + { + if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); + if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + + if (entityId == Constants.System.Root || entityId <= 0) + { + totalRecords = 0; + return Enumerable.Empty(); + } + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + var query = Query().Where(x => x.Id == entityId); + + return _auditRepository.GetPagedResultsByQuery(query, pageIndex, pageSize, out totalRecords, orderDirection, auditTypeFilter, customFilter); + } + } + + /// + /// Returns paged items in the audit trail for a given user + /// + /// + /// + /// + /// + /// + /// By default this will always be ordered descending (newest first) + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query or the custom filter + /// so we need to do that here + /// + /// + /// Optional filter to be applied + /// + /// + public IEnumerable GetPagedItemsByUser(int userId, long pageIndex, int pageSize, out long totalRecords, Direction orderDirection = Direction.Descending, AuditType[] auditTypeFilter = null, IQuery customFilter = null) + { + if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); + if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + + if (userId < 0) + { + totalRecords = 0; + return Enumerable.Empty(); + } + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + var query = Query().Where(x => x.UserId == userId); + + return _auditRepository.GetPagedResultsByQuery(query, pageIndex, pageSize, out totalRecords, orderDirection, auditTypeFilter, customFilter); + } + } + + /// + public IAuditEntry Write(int performingUserId, string perfomingDetails, string performingIp, DateTime eventDateUtc, int affectedUserId, string affectedDetails, string eventType, string eventDetails) + { + if (performingUserId < 0) throw new ArgumentOutOfRangeException(nameof(performingUserId)); + if (string.IsNullOrWhiteSpace(perfomingDetails)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(perfomingDetails)); + if (string.IsNullOrWhiteSpace(eventType)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(eventType)); + if (string.IsNullOrWhiteSpace(eventDetails)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(eventDetails)); + + //we need to truncate the data else we'll get SQL errors + affectedDetails = affectedDetails?.Substring(0, Math.Min(affectedDetails.Length, AuditEntryDto.DetailsLength)); + eventDetails = eventDetails.Substring(0, Math.Min(eventDetails.Length, AuditEntryDto.DetailsLength)); + + //validate the eventType - must contain a forward slash, no spaces, no special chars + var eventTypeParts = eventType.ToCharArray(); + if (eventTypeParts.Contains('/') == false || eventTypeParts.All(c => char.IsLetterOrDigit(c) || c == '/' || c == '-') == false) + throw new ArgumentException(nameof(eventType) + " must contain only alphanumeric characters, hyphens and at least one '/' defining a category"); + if (eventType.Length > AuditEntryDto.EventTypeLength) + throw new ArgumentException($"Must be max {AuditEntryDto.EventTypeLength} chars.", nameof(eventType)); + if (performingIp != null && performingIp.Length > AuditEntryDto.IpLength) + throw new ArgumentException($"Must be max {AuditEntryDto.EventTypeLength} chars.", nameof(performingIp)); + + var entry = new AuditEntry + { + PerformingUserId = performingUserId, + PerformingDetails = perfomingDetails, + PerformingIp = performingIp, + EventDateUtc = eventDateUtc, + AffectedUserId = affectedUserId, + AffectedDetails = affectedDetails, + EventType = eventType, + EventDetails = eventDetails + }; + + if (_isAvailable.Value == false) return entry; + + using (var scope = ScopeProvider.CreateScope()) + { + _auditEntryRepository.Save(entry); + scope.Complete(); + } + + return entry; + } + + //TODO: Currently used in testing only, not part of the interface, need to add queryable methods to the interface instead + internal IEnumerable GetAll() + { + if (_isAvailable.Value == false) return Enumerable.Empty(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + return _auditEntryRepository.GetMany(); + } + } + + //TODO: Currently used in testing only, not part of the interface, need to add queryable methods to the interface instead + internal IEnumerable GetPage(long pageIndex, int pageCount, out long records) + { + if (_isAvailable.Value == false) + { + records = 0; + return Enumerable.Empty(); + } + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + return _auditEntryRepository.GetPage(pageIndex, pageCount, out records); + } + } + + /// + /// Determines whether the repository is available. + /// + private bool DetermineIsAvailable() + { + using (ScopeProvider.CreateScope(autoComplete: true)) + { + return _auditEntryRepository.IsAvailable(); + } + } } } diff --git a/src/Umbraco.Core/Services/Implement/ConsentService.cs b/src/Umbraco.Core/Services/Implement/ConsentService.cs new file mode 100644 index 0000000000..21ec5f4434 --- /dev/null +++ b/src/Umbraco.Core/Services/Implement/ConsentService.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core.Events; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.Scoping; + +namespace Umbraco.Core.Services.Implement +{ + /// + /// Implements . + /// + internal class ConsentService : ScopeRepositoryService, IConsentService + { + private readonly IConsentRepository _consentRepository; + + /// + /// Initializes a new instance of the class. + /// + public ConsentService(IScopeProvider provider, ILogger logger, IEventMessagesFactory eventMessagesFactory, IConsentRepository consentRepository) + : base(provider, logger, eventMessagesFactory) + { + _consentRepository = consentRepository; + } + + /// + public IConsent RegisterConsent(string source, string context, string action, ConsentState state, string comment = null) + { + // prevent stupid states + var v = 0; + if ((state & ConsentState.Pending) > 0) v++; + if ((state & ConsentState.Granted) > 0) v++; + if ((state & ConsentState.Revoked) > 0) v++; + if (v != 1) + throw new ArgumentException("Invalid state.", nameof(state)); + + var consent = new Consent + { + Current = true, + Source = source, + Context = context, + Action = action, + CreateDate = DateTime.Now, + State = state, + Comment = comment + }; + + using (var scope = ScopeProvider.CreateScope()) + { + _consentRepository.ClearCurrent(source, context, action); + _consentRepository.Save(consent); + scope.Complete(); + } + + return consent; + } + + /// + public IEnumerable LookupConsent(string source = null, string context = null, string action = null, + bool sourceStartsWith = false, bool contextStartsWith = false, bool actionStartsWith = false, + bool includeHistory = false) + { + using (ScopeProvider.CreateScope(autoComplete: true)) + { + var query = Query(); + + if (string.IsNullOrWhiteSpace(source) == false) + query = sourceStartsWith ? query.Where(x => x.Source.StartsWith(source)) : query.Where(x => x.Source == source); + if (string.IsNullOrWhiteSpace(context) == false) + query = contextStartsWith ? query.Where(x => x.Context.StartsWith(context)) : query.Where(x => x.Context == context); + if (string.IsNullOrWhiteSpace(action) == false) + query = actionStartsWith ? query.Where(x => x.Action.StartsWith(action)) : query.Where(x => x.Action == action); + if (includeHistory == false) + query = query.Where(x => x.Current); + + return _consentRepository.Get(query); + } + } + } +} diff --git a/src/Umbraco.Core/Services/Implement/ContentService.cs b/src/Umbraco.Core/Services/Implement/ContentService.cs index 0deb396978..789ae30cab 100644 --- a/src/Umbraco.Core/Services/Implement/ContentService.cs +++ b/src/Umbraco.Core/Services/Implement/ContentService.cs @@ -27,7 +27,7 @@ namespace Umbraco.Core.Services.Implement private readonly IContentTypeRepository _contentTypeRepository; private readonly IDocumentBlueprintRepository _documentBlueprintRepository; - private readonly MediaFileSystem _mediaFileSystem; + private readonly MediaFileSystem _mediaFileSystem; private IQuery _queryNotTrashed; #region Constructors @@ -63,7 +63,7 @@ namespace Umbraco.Core.Services.Implement using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.CountPublished(); + return _documentRepository.CountPublished(contentTypeAlias); } } @@ -651,7 +651,7 @@ namespace Umbraco.Core.Services.Implement totalChildren = 0; return Enumerable.Empty(); } - query.Where(x => x.Path.SqlStartsWith($"{contentPath[0]},", TextColumnType.NVarchar)); + query.Where(x => x.Path.SqlStartsWith($"{contentPath[0].Path},", TextColumnType.NVarchar)); } return _documentRepository.GetPage(query, pageIndex, pageSize, out totalChildren, orderBy, orderDirection, orderBySystemField, filter); } @@ -800,8 +800,7 @@ namespace Umbraco.Core.Services.Implement using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); - var bin = $"{Constants.System.Root},{Constants.System.RecycleBinContent},"; - var query = Query().Where(x => x.Path.StartsWith(bin)); + var query = Query().Where(x => x.Path.StartsWith(Constants.System.RecycleBinContentPathPrefix)); return _documentRepository.Get(query); } } @@ -1709,7 +1708,7 @@ namespace Umbraco.Core.Services.Implement /// /// Sorts a collection of objects by updating the SortOrder according - /// to the ordering of items in the passed in . + /// to the ordering of items in the passed in . /// /// /// Using this method will ensure that the Published-state is maintained upon sorting @@ -1726,56 +1725,88 @@ namespace Umbraco.Core.Services.Implement using (var scope = ScopeProvider.CreateScope()) { - var saveEventArgs = new SaveEventArgs(itemsA); - if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, "Saving")) - return false; - - var published = new List(); - var saved = new List(); - scope.WriteLock(Constants.Locks.ContentTree); - var sortOrder = 0; - - foreach (var content in itemsA) - { - // if the current sort order equals that of the content we don't - // need to update it, so just increment the sort order and continue. - if (content.SortOrder == sortOrder) - { - sortOrder++; - continue; - } - - // else update - content.SortOrder = sortOrder++; - content.WriterId = userId; - - // if it's published, register it, no point running StrategyPublish - // since we're not really publishing it and it cannot be cancelled etc - if (content.Published) - published.Add(content); - - // save - saved.Add(content); - _documentRepository.Save(content); - } - - if (raiseEvents) - { - saveEventArgs.CanCancel = false; - scope.Events.Dispatch(Saved, this, saveEventArgs, "Saved"); - } - - scope.Events.Dispatch(TreeChanged, this, saved.Select(x => new TreeChange(x, TreeChangeTypes.RefreshNode)).ToEventArgs()); - - if (raiseEvents && published.Any()) - scope.Events.Dispatch(Published, this, new PublishEventArgs(published, false, false), "Published"); - - Audit(AuditType.Sort, "Sorting content performed by user", userId, 0); + var ret = Sort(scope, itemsA, userId, raiseEvents); scope.Complete(); + return ret; + } + } + + /// + /// Sorts a collection of objects by updating the SortOrder according + /// to the ordering of items identified by the . + /// + /// + /// Using this method will ensure that the Published-state is maintained upon sorting + /// so the cache is updated accordingly - as needed. + /// + /// + /// + /// + /// True if sorting succeeded, otherwise False + public bool Sort(IEnumerable ids, int userId = 0, bool raiseEvents = true) + { + var idsA = ids.ToArray(); + if (idsA.Length == 0) return true; + + using (var scope = ScopeProvider.CreateScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + var itemsA = GetByIds(idsA).ToArray(); + + var ret = Sort(scope, itemsA, userId, raiseEvents); + scope.Complete(); + return ret; + } + } + + private bool Sort(IScope scope, IContent[] itemsA, int userId, bool raiseEvents) + { + var saveEventArgs = new SaveEventArgs(itemsA); + if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, "Saving")) + return false; + + var published = new List(); + var saved = new List(); + var sortOrder = 0; + + foreach (var content in itemsA) + { + // if the current sort order equals that of the content we don't + // need to update it, so just increment the sort order and continue. + if (content.SortOrder == sortOrder) + { + sortOrder++; + continue; + } + + // else update + content.SortOrder = sortOrder++; + content.WriterId = userId; + + // if it's published, register it, no point running StrategyPublish + // since we're not really publishing it and it cannot be cancelled etc + if (content.Published) + published.Add(content); + + // save + saved.Add(content); + _documentRepository.Save(content); } + if (raiseEvents) + { + saveEventArgs.CanCancel = false; + scope.Events.Dispatch(Saved, this, saveEventArgs, "Saved"); + } + + scope.Events.Dispatch(TreeChanged, this, saved.Select(x => new TreeChange(x, TreeChangeTypes.RefreshNode)).ToEventArgs()); + + if (raiseEvents && published.Any()) + scope.Events.Dispatch(Published, this, new PublishEventArgs(published, false, false), "Published"); + + Audit(AuditType.Sort, "Sorting content performed by user", userId, 0); return true; } @@ -2302,6 +2333,38 @@ namespace Umbraco.Core.Services.Implement } } + public void DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, int userId = 0) + { + using (var scope = ScopeProvider.CreateScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + + var contentTypeIdsA = contentTypeIds.ToArray(); + var query = Query(); + if (contentTypeIdsA.Length > 0) + query.Where(x => contentTypeIdsA.Contains(x.ContentTypeId)); + + var blueprints = _documentBlueprintRepository.Get(query).Select(x => + { + ((Content) x).Blueprint = true; + return x; + }).ToArray(); + + foreach (var blueprint in blueprints) + { + _documentBlueprintRepository.Delete(blueprint); + } + + scope.Events.Dispatch(DeletedBlueprint, this, new DeleteEventArgs(blueprints), "DeletedBlueprint"); + scope.Complete(); + } + } + + public void DeleteBlueprintsOfType(int contentTypeId, int userId = 0) + { + DeleteBlueprintsOfTypes(new[] { contentTypeId }, userId); + } + #endregion } } diff --git a/src/Umbraco.Core/Services/Implement/ContentTypeService.cs b/src/Umbraco.Core/Services/Implement/ContentTypeService.cs index cbbe9e6f63..f6498770ec 100644 --- a/src/Umbraco.Core/Services/Implement/ContentTypeService.cs +++ b/src/Umbraco.Core/Services/Implement/ContentTypeService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Umbraco.Core.Events; using Umbraco.Core.Logging; using Umbraco.Core.Models; @@ -32,8 +33,13 @@ namespace Umbraco.Core.Services.Implement protected override void DeleteItemsOfTypes(IEnumerable typeIds) { - foreach (var typeId in typeIds) - ContentService.DeleteOfType(typeId); + using (var scope = ScopeProvider.CreateScope()) + { + var typeIdsA = typeIds.ToArray(); + ContentService.DeleteOfTypes(typeIdsA); + ContentService.DeleteBlueprintsOfTypes(typeIdsA); + scope.Complete(); + } } /// @@ -82,6 +88,5 @@ namespace Umbraco.Core.Services.Implement return Repository.GetAllContentTypeIds(aliases); } } - } } diff --git a/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTItemTService.cs b/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTItemTService.cs index 63c5340a6c..a3db23e5ed 100644 --- a/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTItemTService.cs +++ b/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTItemTService.cs @@ -130,7 +130,7 @@ namespace Umbraco.Core.Services.Implement protected void OnDeletedContainer(IScope scope, DeleteEventArgs args) { - scope.Events.Dispatch(DeletedContainer, This, args); + scope.Events.Dispatch(DeletedContainer, This, args, "DeletedContainer"); } } } diff --git a/src/Umbraco.Core/Services/Implement/MediaService.cs b/src/Umbraco.Core/Services/Implement/MediaService.cs index 78c3032df2..dec4e56714 100644 --- a/src/Umbraco.Core/Services/Implement/MediaService.cs +++ b/src/Umbraco.Core/Services/Implement/MediaService.cs @@ -445,7 +445,7 @@ namespace Umbraco.Core.Services.Implement //null check otherwise we get exceptions if (media.Path.IsNullOrWhiteSpace()) return Enumerable.Empty(); - var rootId = Constants.System.Root.ToInvariantString(); + var rootId = Constants.System.RootString; var ids = media.Path.Split(',') .Where(x => x != rootId && x != media.Id.ToString(CultureInfo.InvariantCulture)) .Select(int.Parse) @@ -616,7 +616,7 @@ namespace Umbraco.Core.Services.Implement totalChildren = 0; return Enumerable.Empty(); } - query.Where(x => x.Path.SqlStartsWith(mediaPath[0] + ",", TextColumnType.NVarchar)); + query.Where(x => x.Path.SqlStartsWith(mediaPath[0].Path + ",", TextColumnType.NVarchar)); } return _mediaRepository.GetPage(query, pageIndex, pageSize, out totalChildren, orderBy, orderDirection, orderBySystemField, filter); } @@ -706,8 +706,7 @@ namespace Umbraco.Core.Services.Implement using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.MediaTree); - var bin = $"{Constants.System.Root},{Constants.System.RecycleBinMedia},"; - var query = Query().Where(x => x.Path.StartsWith(bin)); + var query = Query().Where(x => x.Path.StartsWith(Constants.System.RecycleBinMediaPathPrefix)); return _mediaRepository.Get(query); } } @@ -734,7 +733,7 @@ namespace Umbraco.Core.Services.Implement /// public IMedia GetMediaByPath(string mediaPath) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (ScopeProvider.CreateScope(autoComplete: true)) { return _mediaRepository.GetMediaByPath(mediaPath); } diff --git a/src/Umbraco.Core/Services/Implement/MemberService.cs b/src/Umbraco.Core/Services/Implement/MemberService.cs index 83ec78932f..f8245f18c2 100644 --- a/src/Umbraco.Core/Services/Implement/MemberService.cs +++ b/src/Umbraco.Core/Services/Implement/MemberService.cs @@ -403,7 +403,7 @@ namespace Umbraco.Core.Services.Implement { scope.ReadLock(Constants.Locks.MemberTree); var query1 = memberTypeAlias == null ? null : Query().Where(x => x.ContentTypeAlias == memberTypeAlias); - var query2 = filter == null ? null : Query().Where(x => x.Name.Contains(filter) || x.Username.Contains(filter)); + var query2 = filter == null ? null : Query().Where(x => x.Name.Contains(filter) || x.Username.Contains(filter) || x.Email.Contains(filter)); return _memberRepository.GetPage(query1, pageIndex, pageSize, out totalRecords, orderBy, orderDirection, orderBySystemField, query2); } } @@ -815,6 +815,10 @@ namespace Umbraco.Core.Services.Implement /// Default is True otherwise set to False to not raise events public void Save(IMember member, bool raiseEvents = true) { + //trimming username and email to make sure we have no trailing space + member.Username = member.Username.Trim(); + member.Email = member.Email.Trim(); + using (var scope = ScopeProvider.CreateScope()) { var saveEventArgs = new SaveEventArgs(member); @@ -866,7 +870,13 @@ namespace Umbraco.Core.Services.Implement scope.WriteLock(Constants.Locks.MemberTree); foreach (var member in membersA) + { + //trimming username and email to make sure we have no trailing space + member.Username = member.Username.Trim(); + member.Email = member.Email.Trim(); + _memberRepository.Save(member); + } if (raiseEvents) { @@ -1018,7 +1028,9 @@ namespace Umbraco.Core.Services.Implement using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.MemberTree); - _memberGroupRepository.AssignRoles(usernames, roleNames); + var ids = _memberGroupRepository.GetMemberIds(usernames); + _memberGroupRepository.AssignRoles(ids, roleNames); + scope.Events.Dispatch(AssignedRoles, this, new RolesEventArgs(ids, roleNames)); scope.Complete(); } } @@ -1033,7 +1045,9 @@ namespace Umbraco.Core.Services.Implement using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.MemberTree); - _memberGroupRepository.DissociateRoles(usernames, roleNames); + var ids = _memberGroupRepository.GetMemberIds(usernames); + _memberGroupRepository.DissociateRoles(ids, roleNames); + scope.Events.Dispatch(RemovedRoles, this, new RolesEventArgs(ids, roleNames)); scope.Complete(); } } @@ -1049,6 +1063,7 @@ namespace Umbraco.Core.Services.Implement { scope.WriteLock(Constants.Locks.MemberTree); _memberGroupRepository.AssignRoles(memberIds, roleNames); + scope.Events.Dispatch(AssignedRoles, this, new RolesEventArgs(memberIds, roleNames)); scope.Complete(); } } @@ -1064,6 +1079,7 @@ namespace Umbraco.Core.Services.Implement { scope.WriteLock(Constants.Locks.MemberTree); _memberGroupRepository.DissociateRoles(memberIds, roleNames); + scope.Events.Dispatch(RemovedRoles, this, new RolesEventArgs(memberIds, roleNames)); scope.Complete(); } } @@ -1110,6 +1126,21 @@ namespace Umbraco.Core.Services.Implement /// public static event TypedEventHandler> Saved; + /// + /// Occurs after roles have been assigned. + /// + public static event TypedEventHandler AssignedRoles; + + /// + /// Occurs after roles have been removed. + /// + public static event TypedEventHandler RemovedRoles; + + /// + /// Occurs after members have been exported. + /// + internal static event TypedEventHandler Exported; + #endregion #region Membership @@ -1219,6 +1250,72 @@ namespace Umbraco.Core.Services.Implement return member; } + /// + /// Exports a member. + /// + /// + /// This is internal for now and is used to export a member in the member editor, + /// it will raise an event so that auditing logs can be created. + /// + internal MemberExportModel ExportMember(Guid key) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + var query = Query().Where(x => x.Key == key); + var member = _memberRepository.Get(query).FirstOrDefault(); + + if (member == null) return null; + + var model = new MemberExportModel + { + Id = member.Id, + Key = member.Key, + Name = member.Name, + Username = member.Username, + Email = member.Email, + Groups = GetAllRoles(member.Id).ToList(), + ContentTypeAlias = member.ContentTypeAlias, + CreateDate = member.CreateDate, + UpdateDate = member.UpdateDate, + Properties = new List(GetPropertyExportItems(member)) + }; + + scope.Events.Dispatch(Exported, this, new ExportedMemberEventArgs(member, model)); + + return model; + } + } + + private static IEnumerable GetPropertyExportItems(IMember member) + { + if (member == null) throw new ArgumentNullException(nameof(member)); + + var exportProperties = new List(); + + foreach (var property in member.Properties) + { + //ignore list + switch (property.Alias) + { + case Constants.Conventions.Member.PasswordQuestion: + continue; + } + + var propertyExportModel = new MemberExportProperty + { + Id = property.Id, + Alias = property.Alias, + Name = property.PropertyType.Name, + Value = property.GetValue(), // fixme ignoring variants + CreateDate = property.CreateDate, + UpdateDate = property.UpdateDate + }; + exportProperties.Add(propertyExportModel); + } + + return exportProperties; + } + #endregion #region Content Types diff --git a/src/Umbraco.Core/Services/Implement/PackagingService.cs b/src/Umbraco.Core/Services/Implement/PackagingService.cs index ea698fc8c9..b6ddc400d8 100644 --- a/src/Umbraco.Core/Services/Implement/PackagingService.cs +++ b/src/Umbraco.Core/Services/Implement/PackagingService.cs @@ -768,21 +768,18 @@ namespace Umbraco.Core.Services.Implement foreach (var element in structureElement.Elements("DocumentType")) { var alias = element.Value; - if (_importedContentTypes.ContainsKey(alias)) - { - var allowedChild = _importedContentTypes[alias]; - if (allowedChild == null || allowedChildren.Any(x => x.Id.IsValueCreated && x.Id.Value == allowedChild.Id)) continue; - allowedChildren.Add(new ContentTypeSort(new Lazy(() => allowedChild.Id), sortOrder, allowedChild.Alias)); - sortOrder++; - } - else + var allowedChild = _importedContentTypes.ContainsKey(alias) ? _importedContentTypes[alias] : _contentTypeService.Get(alias); + if (allowedChild == null) { - _logger.Warn( - string.Format( - "Packager: Error handling DocumentType structure. DocumentType with alias '{0}' could not be found and was not added to the structure for '{1}'.", - alias, contentType.Alias)); + _logger.Warn($"Packager: Error handling DocumentType structure. DocumentType with alias '{alias}' could not be found and was not added to the structure for '{contentType.Alias}'."); + continue; } + + if (allowedChildren.Any(x => x.Id.IsValueCreated && x.Id.Value == allowedChild.Id)) continue; + + allowedChildren.Add(new ContentTypeSort(new Lazy(() => allowedChild.Id), sortOrder, allowedChild.Alias)); + sortOrder++; } contentType.AllowedContentTypes = allowedChildren; @@ -1679,9 +1676,10 @@ namespace Umbraco.Core.Services.Implement internal InstallationSummary InstallPackage(string packageFilePath, int userId = 0, bool raiseEvents = false) { + var metaData = GetPackageMetaData(packageFilePath); + if (raiseEvents) { - var metaData = GetPackageMetaData(packageFilePath); if (ImportingPackage.IsRaisedEventCancelled(new ImportPackageEventArgs(packageFilePath, metaData), this)) { var initEmpty = new InstallationSummary().InitEmpty(); @@ -1693,7 +1691,7 @@ namespace Umbraco.Core.Services.Implement if (raiseEvents) { - ImportedPackage.RaiseEvent(new ImportPackageEventArgs(installationSummary, false), this); + ImportedPackage.RaiseEvent(new ImportPackageEventArgs(installationSummary, metaData, false), this); } return installationSummary; diff --git a/src/Umbraco.Core/Services/Implement/UserService.cs b/src/Umbraco.Core/Services/Implement/UserService.cs index 598f742e60..43d438da0b 100644 --- a/src/Umbraco.Core/Services/Implement/UserService.cs +++ b/src/Umbraco.Core/Services/Implement/UserService.cs @@ -224,9 +224,9 @@ namespace Umbraco.Core.Services.Implement } /// - /// Deletes an + /// Disables an /// - /// to Delete + /// to disable public void Delete(IUser membershipUser) { //disable @@ -542,6 +542,42 @@ namespace Umbraco.Core.Services.Implement } } + public Guid CreateLoginSession(int userId, string requestingIpAddress) + { + using (var scope = ScopeProvider.CreateScope()) + { + var session = _userRepository.CreateLoginSession(userId, requestingIpAddress); + scope.Complete(); + return session; + } + } + + public int ClearLoginSessions(int userId) + { + using (var scope = ScopeProvider.CreateScope()) + { + var count = _userRepository.ClearLoginSessions(userId); + scope.Complete(); + return count; + } + } + + public void ClearLoginSession(Guid sessionId) + { + using (var scope = ScopeProvider.CreateScope()) + { + _userRepository.ClearLoginSession(sessionId); + scope.Complete(); + } + } + + public bool ValidateLoginSession(int userId, Guid sessionId) + { + using (ScopeProvider.CreateScope(autoComplete: true)) + { + return _userRepository.ValidateLoginSession(userId, sessionId); + } + } public IDictionary GetUserStates() { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -747,8 +783,9 @@ namespace Umbraco.Core.Services.Implement _userGroupRepository.ReplaceGroupPermissions(groupId, permissions, entityIds); scope.Complete(); - scope.Events.Dispatch(UserGroupPermissionsAssigned, this, new SaveEventArgs(entityIds.Select(x => new EntityPermission(groupId, x, permissions.Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray())) - .ToArray(), false)); + var assigned = permissions.Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); + scope.Events.Dispatch(UserGroupPermissionsAssigned, this, + new SaveEventArgs(entityIds.Select(x => new EntityPermission(groupId, x, assigned)).ToArray(), false)); } } @@ -768,8 +805,9 @@ namespace Umbraco.Core.Services.Implement _userGroupRepository.AssignGroupPermission(groupId, permission, entityIds); scope.Complete(); - scope.Events.Dispatch(UserGroupPermissionsAssigned, this, new SaveEventArgs(entityIds.Select(x => new EntityPermission(groupId, x, new[] { permission.ToString(CultureInfo.InvariantCulture) })) - .ToArray(), false)); + var assigned = new[] { permission.ToString(CultureInfo.InvariantCulture) }; + scope.Events.Dispatch(UserGroupPermissionsAssigned, this, + new SaveEventArgs(entityIds.Select(x => new EntityPermission(groupId, x, assigned)).ToArray(), false)); } } @@ -842,7 +880,23 @@ namespace Umbraco.Core.Services.Implement { using (var scope = ScopeProvider.CreateScope()) { - var saveEventArgs = new SaveEventArgs(userGroup); + // we need to figure out which users have been added / removed, for audit purposes + var empty = new IUser[0]; + var addedUsers = empty; + var removedUsers = empty; + + if (userIds != null) + { + var groupUsers = userGroup.HasIdentity ? _userRepository.GetAllInGroup(userGroup.Id).ToArray() : empty; + var xGroupUsers = groupUsers.ToDictionary(x => x.Id, x => x); + var groupIds = groupUsers.Select(x => x.Id).ToArray(); + + addedUsers = _userRepository.GetMany(userIds.Except(groupIds).ToArray()).Where(x => x.Id != 0).ToArray(); + removedUsers = groupIds.Except(userIds).Select(x => xGroupUsers[x]).Where(x => x.Id != 0).ToArray(); + } + + var saveEventArgs = new SaveEventArgs(new UserGroupWithUsers(userGroup, addedUsers, removedUsers)); + if (raiseEvents && scope.Events.DispatchCancelable(SavingUserGroup, this, saveEventArgs)) { scope.Complete(); @@ -1183,12 +1237,12 @@ namespace Umbraco.Core.Services.Implement /// /// Occurs before Save /// - public static event TypedEventHandler> SavingUserGroup; + internal static event TypedEventHandler> SavingUserGroup; /// /// Occurs after Save /// - public static event TypedEventHandler> SavedUserGroup; + internal static event TypedEventHandler> SavedUserGroup; /// /// Occurs before Delete diff --git a/src/Umbraco.Core/Services/ServiceContext.cs b/src/Umbraco.Core/Services/ServiceContext.cs index c8bdc7b2fd..ffc3bdfd31 100644 --- a/src/Umbraco.Core/Services/ServiceContext.cs +++ b/src/Umbraco.Core/Services/ServiceContext.cs @@ -34,12 +34,13 @@ namespace Umbraco.Core.Services private readonly Lazy _notificationService; private readonly Lazy _externalLoginService; private readonly Lazy _redirectUrlService; + private readonly Lazy _consentService; /// /// Initializes a new instance of the class with lazy services. /// /// Used by IoC. Note that LightInject will favor lazy args when picking a constructor. - public ServiceContext(Lazy publicAccessService, Lazy taskService, Lazy domainService, Lazy auditService, Lazy localizedTextService, Lazy tagService, Lazy contentService, Lazy userService, Lazy memberService, Lazy mediaService, Lazy contentTypeService, Lazy mediaTypeService, Lazy dataTypeService, Lazy fileService, Lazy localizationService, Lazy packagingService, Lazy serverRegistrationService, Lazy entityService, Lazy relationService, Lazy treeService, Lazy sectionService, Lazy macroService, Lazy memberTypeService, Lazy memberGroupService, Lazy notificationService, Lazy externalLoginService, Lazy redirectUrlService) + public ServiceContext(Lazy publicAccessService, Lazy taskService, Lazy domainService, Lazy auditService, Lazy localizedTextService, Lazy tagService, Lazy contentService, Lazy userService, Lazy memberService, Lazy mediaService, Lazy contentTypeService, Lazy mediaTypeService, Lazy dataTypeService, Lazy fileService, Lazy localizationService, Lazy packagingService, Lazy serverRegistrationService, Lazy entityService, Lazy relationService, Lazy treeService, Lazy sectionService, Lazy macroService, Lazy memberTypeService, Lazy memberGroupService, Lazy notificationService, Lazy externalLoginService, Lazy redirectUrlService, Lazy consentService) { _publicAccessService = publicAccessService; _taskService = taskService; @@ -68,14 +69,14 @@ namespace Umbraco.Core.Services _notificationService = notificationService; _externalLoginService = externalLoginService; _redirectUrlService = redirectUrlService; + _consentService = consentService; } /// /// Initializes a new instance of the class with services. /// /// Used in tests. All items are optional and remain null if not specified. - public ServiceContext( - IContentService contentService = null, + public ServiceContext(IContentService contentService = null, IMediaService mediaService = null, IContentTypeService contentTypeService = null, IMediaTypeService mediaTypeService = null, @@ -101,7 +102,8 @@ namespace Umbraco.Core.Services IPublicAccessService publicAccessService = null, IExternalLoginService externalLoginService = null, IServerRegistrationService serverRegistrationService = null, - IRedirectUrlService redirectUrlService = null) + IRedirectUrlService redirectUrlService = null, + IConsentService consentService = null) { if (serverRegistrationService != null) _serverRegistrationService = new Lazy(() => serverRegistrationService); if (externalLoginService != null) _externalLoginService = new Lazy(() => externalLoginService); @@ -130,6 +132,7 @@ namespace Umbraco.Core.Services if (macroService != null) _macroService = new Lazy(() => macroService); if (publicAccessService != null) _publicAccessService = new Lazy(() => publicAccessService); if (redirectUrlService != null) _redirectUrlService = new Lazy(() => redirectUrlService); + if (consentService != null) _consentService = new Lazy(() => consentService); } /// @@ -256,12 +259,20 @@ namespace Umbraco.Core.Services /// Gets the MemberGroupService /// public IMemberGroupService MemberGroupService => _memberGroupService.Value; - + + /// + /// Gets the ExternalLoginService. + /// public IExternalLoginService ExternalLoginService => _externalLoginService.Value; /// /// Gets the RedirectUrlService. /// public IRedirectUrlService RedirectUrlService => _redirectUrlService.Value; + + /// + /// Gets the ConsentService. + /// + public IConsentService ConsentService => _consentService.Value; } } diff --git a/src/Umbraco.Core/UdiGetterExtensions.cs b/src/Umbraco.Core/UdiGetterExtensions.cs index e94f228e4c..3ba5fe6f65 100644 --- a/src/Umbraco.Core/UdiGetterExtensions.cs +++ b/src/Umbraco.Core/UdiGetterExtensions.cs @@ -132,7 +132,7 @@ namespace Umbraco.Core public static GuidUdi GetUdi(this IContent entity) { if (entity == null) throw new ArgumentNullException("entity"); - return new GuidUdi(entity.Blueprint ? Constants.UdiEntityType.DocumentBluePrint : Constants.UdiEntityType.Document, entity.Key).EnsureClosed(); + return new GuidUdi(entity.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, entity.Key).EnsureClosed(); } /// diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 9719a5f223..6a19b00f22 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -98,7 +98,7 @@ - + @@ -377,6 +377,7 @@ + @@ -395,6 +396,7 @@ + @@ -1349,6 +1351,7 @@ + @@ -1357,6 +1360,7 @@ + diff --git a/src/Umbraco.Core/UriExtensions.cs b/src/Umbraco.Core/UriExtensions.cs index d58814590d..88180428c4 100644 --- a/src/Umbraco.Core/UriExtensions.cs +++ b/src/Umbraco.Core/UriExtensions.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using Umbraco.Core.Composing; using Umbraco.Core.Configuration; using Umbraco.Core.IO; using Umbraco.Core.Logging; @@ -152,7 +153,7 @@ namespace Umbraco.Core } catch (ArgumentException ex) { - LogHelper.Error(typeof(UriExtensions), "Failed to determine if request was client side", ex); + Current.Logger.Error(typeof(UriExtensions), "Failed to determine if request was client side", ex); return false; } } diff --git a/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs b/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs index 5dee154638..d0cafaabc4 100644 --- a/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text.RegularExpressions; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Core; using Umbraco.Core.PropertyEditors; @@ -9,12 +10,11 @@ using Umbraco.Core.Services; namespace Umbraco.Web.PropertyEditors { - internal class ColorPickerConfigurationEditor : ValueListConfigurationEditor + internal class ColorPickerConfigurationEditor : ConfigurationEditor { - public ColorPickerConfigurationEditor(ILocalizedTextService textService) - : base(textService) + public ColorPickerConfigurationEditor() { - var field = Fields.First(); + var field = Fields.First(x => x.Key == "items"); //use a custom editor too field.View = "views/propertyeditors/colorpicker/colorpicker.prevalues.html"; @@ -26,23 +26,84 @@ namespace Umbraco.Web.PropertyEditors field.Validators.Add(new ColorListValidator()); } - public override Dictionary ToConfigurationEditor(ValueListConfiguration configuration) + public override Dictionary ToConfigurationEditor(ColorPickerConfiguration configuration) { if (configuration == null) return new Dictionary { - { "items", new object() } + { "items", new object() }, + { "useLabel", false } }; - // for now, we have to do this, because the color picker is weird, but it's fixed in 7.7 at some point - // and then we probably don't need this whole override method anymore - base shouls be enough? + var items = configuration.Items.ToDictionary(x => x.Id.ToString(), x => GetItemValue(x, configuration.UseLabel)); return new Dictionary { - { "items", configuration.Items.ToDictionary(x => x.Id.ToString(), x => x.Value) } + { "items", items }, + { "useLabel", configuration.UseLabel } }; } + private object GetItemValue(ValueListConfiguration.ValueListItem item, bool useLabel) + { + if (useLabel) + { + return item.Value.DetectIsJson() + ? JsonConvert.DeserializeObject(item.Value) + : new JObject { { "color", item.Value }, { "label", item.Value } }; + } + + if (!item.Value.DetectIsJson()) + return item.Value; + + var jobject = (JObject) JsonConvert.DeserializeObject(item.Value); + return jobject.Property("color").Value.Value(); + } + + public override ColorPickerConfiguration FromConfigurationEditor(Dictionary editorValues, ColorPickerConfiguration configuration) + { + var output = new ColorPickerConfiguration(); + + if (!editorValues.TryGetValue("items", out var jjj) || !(jjj is JArray jItems)) + return output; // oops + + // handle useLabel + if (editorValues.TryGetValue("useLabel", out var useLabelObj)) + output.UseLabel = useLabelObj.TryConvertTo(); + + // auto-assigning our ids, get next id from existing values + var nextId = 1; + if (configuration?.Items != null && configuration.Items.Count > 0) + nextId = configuration.Items.Max(x => x.Id) + 1; + + // create ValueListItem instances - sortOrder is ignored here + foreach (var item in jItems.OfType()) + { + var value = item.Property("value")?.Value?.Value(); + if (string.IsNullOrWhiteSpace(value)) continue; + + var id = item.Property("id")?.Value?.Value() ?? 0; + if (id >= nextId) nextId = id + 1; + + // if using a label, replace color by json blob + // (a pity we have to serialize here!) + if (output.UseLabel) + { + var label = item.Property("label")?.Value?.Value(); + value = JsonConvert.SerializeObject(new { value, label }); + } + + output.Items.Add(new ValueListConfiguration.ValueListItem { Id = id, Value = value }); + } + + // ensure ids + foreach (var item in output.Items) + if (item.Id == 0) + item.Id = nextId++; + + return output; + } + internal class ColorListValidator : IValueValidator { public IEnumerable Validate(object value, string valueType, object dataTypeConfiguration) diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 20358b3d4d..88f9430f2b 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -298,7 +298,6 @@ - From 0a4878d2a329487510272774edd8ae3cc365477f Mon Sep 17 00:00:00 2001 From: Stephan Date: Tue, 27 Mar 2018 10:04:07 +0200 Subject: [PATCH 08/15] Port v7@2aa0dfb2c5 - WIP --- .../Configuration/UmbracoVersion.cs | 39 ++- src/Umbraco.Core/Runtime/CoreRuntime.cs | 20 +- .../Sync/DatabaseServerMessenger.cs | 5 +- .../Sync/DatabaseServerRegistrar.cs | 14 +- src/Umbraco.Core/Umbraco.Core.csproj | 2 +- src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 7 +- src/Umbraco.Web.UI/packages.config | 2 +- .../BatchedDatabaseServerMessenger.cs | 22 +- .../Cache/CacheRefresherComponent.cs | 8 +- .../Cache/MemberGroupCacheRefresher.cs | 39 +-- ...aseServerRegistrarAndMessengerComponent.cs | 121 +++++---- ...acyServerRegistrarAndMessengerComponent.cs | 8 +- .../NotificationsComponent.cs | 6 +- .../PublicAccessComponent.cs | 2 +- .../WebMappingProfilesCompositionRoot.cs | 3 +- .../Controllers/UmbRegisterController.cs | 7 + .../Editors/AuthenticationController.cs | 21 +- .../Editors/BackOfficeController.cs | 164 ++++++------ .../Editors/BackOfficeServerVariables.cs | 15 +- src/Umbraco.Web/Editors/ContentController.cs | 49 ++-- .../Editors/CurrentUserController.cs | 57 ++++- src/Umbraco.Web/Editors/DashboardHelper.cs | 1 + src/Umbraco.Web/Editors/DataTypeController.cs | 3 + .../Editors/EditorModelEventArgs.cs | 33 +++ src/Umbraco.Web/Editors/HelpController.cs | 43 ++++ src/Umbraco.Web/Editors/LogController.cs | 71 +++++- src/Umbraco.Web/Editors/MediaController.cs | 150 ++++++----- src/Umbraco.Web/Editors/MemberController.cs | 100 ++++++-- .../Editors/MemberTypeController.cs | 40 ++- src/Umbraco.Web/Editors/PasswordChanger.cs | 3 +- src/Umbraco.Web/Editors/SectionController.cs | 2 +- src/Umbraco.Web/Editors/TourController.cs | 138 ++++++++++ src/Umbraco.Web/Editors/UsersController.cs | 28 +- src/Umbraco.Web/ExamineStartup.cs | 212 +++++++++++++++ .../Checks/Config/AbstractConfigCheck.cs | 5 +- .../HealthCheck/HealthCheckController.cs | 3 + src/Umbraco.Web/HtmlStringUtilities.cs | 10 +- src/Umbraco.Web/IPublishedContentQuery.cs | 21 +- .../Install/Controllers/InstallController.cs | 6 +- src/Umbraco.Web/Install/InstallHelper.cs | 3 +- .../Install/InstallSteps/UpgradeStep.cs | 74 +----- src/Umbraco.Web/Models/BackOfficeTour.cs | 27 ++ src/Umbraco.Web/Models/BackOfficeTourFile.cs | 30 +++ .../Models/BackOfficeTourFilter.cs | 61 +++++ src/Umbraco.Web/Models/BackOfficeTourStep.cs | 33 +++ .../Models/ContentEditing/AuditLog.cs | 19 +- .../ContentEditing/ContentItemDisplay.cs | 7 +- .../ContentEditing/ContentPropertyBasic.cs | 5 + .../ContentEditing/ContentPropertyDisplay.cs | 3 + .../Models/ContentEditing/EditorNavigation.cs | 26 ++ .../Models/ContentEditing/EntityBasic.cs | 3 - .../Models/ContentEditing/MediaItemDisplay.cs | 4 + .../ContentEditing/MemberPropertyTypeBasic.cs | 3 + .../MemberPropertyTypeDisplay.cs | 3 + .../Models/ContentEditing/PostedFolder.cs | 17 ++ .../Models/ContentEditing/UserDetail.cs | 2 - .../Models/ContentEditing/UserDisplay.cs | 5 + .../Models/Mapping/ActionButtonsResolver.cs | 37 +-- .../Models/Mapping/AuditMapperProfile.cs | 20 ++ .../Models/Mapping/AutoMapperExtensions.cs | 31 +++ .../Mapping/ContentChildOfListViewResolver.cs | 26 ++ .../Models/Mapping/ContentMapperProfile.cs | 192 +++----------- .../Mapping/ContentPropertyBasicConverter.cs | 8 +- .../ContentPropertyDisplayConverter.cs | 13 +- .../Mapping/ContentPropertyDtoConverter.cs | 2 +- .../Mapping/ContentPropertyMapperProfile.cs | 22 +- .../Mapping/ContentTreeNodeUrlResolver.cs | 24 ++ .../Mapping/ContentTypeBasicResolver.cs | 39 +++ .../Mapping/ContentTypeMapperProfile.cs | 65 ++--- .../Models/Mapping/DefaultTemplateResolver.cs | 24 ++ .../Models/Mapping/EntityMapperProfile.cs | 2 +- .../Mapping/MediaChildOfListViewResolver.cs | 26 ++ .../Models/Mapping/MediaMapperProfile.cs | 97 ++----- .../Mapping/MemberBasicPropertiesResolver.cs | 42 +++ .../Models/Mapping/MemberMapperProfile.cs | 196 +------------- .../MemberTabsAndPropertiesResolver.cs | 211 +++++++++++++-- .../Mapping/MemberTreeNodeUrlResolver.cs | 23 ++ .../Mapping/MembershipScenarioResolver.cs | 6 +- .../Mapping/MembershipUserTypeConverter.cs | 21 ++ ...Profile.cs => RedirectUrlMapperProfile.cs} | 12 +- .../Mapping/TabsAndPropertiesResolver.cs | 241 +++++++++--------- .../Models/Mapping/UserMapperProfile.cs | 55 ++-- src/Umbraco.Web/Models/Trees/ExportMember.cs | 9 + src/Umbraco.Web/Models/Trees/MenuItem.cs | 2 +- src/Umbraco.Web/Models/Trees/MenuItemList.cs | 2 +- src/Umbraco.Web/Models/UserTourStatus.cs | 60 +++++ src/Umbraco.Web/Mvc/ContentModelBinder.cs | 71 +++++- src/Umbraco.Web/Mvc/RenderRouteHandler.cs | 20 +- src/Umbraco.Web/Properties/AssemblyInfo.cs | 3 + src/Umbraco.Web/PublishedContentQuery.cs | 120 ++++++--- .../Runtime/WebRuntimeComponent.cs | 67 +++-- src/Umbraco.Web/Scheduling/KeepAlive.cs | 12 + .../Scheduling/LatchedBackgroundTaskBase.cs | 2 +- .../Scheduling/ScheduledPublishing.cs | 26 +- src/Umbraco.Web/Scheduling/ScheduledTasks.cs | 3 + .../Scheduling/SchedulerComponent.cs | 8 +- src/Umbraco.Web/Tour/BackOfficeTourFilter.cs | 63 +++++ src/Umbraco.Web/Tour/TourFilterCollection.cs | 18 ++ .../Tour/TourFilterCollectionBuilder.cs | 81 ++++++ .../Trees/ApplicationTreeController.cs | 15 +- .../Trees/ContentTreeControllerBase.cs | 112 ++++---- .../Trees/LegacyTreeDataConverter.cs | 5 +- src/Umbraco.Web/Trees/MacroTreeController.cs | 73 ++++++ src/Umbraco.Web/Trees/MemberTreeController.cs | 12 + src/Umbraco.Web/Trees/UserTreeController.cs | 30 ++- src/Umbraco.Web/Trees/XsltTreeController.cs | 54 ++++ src/Umbraco.Web/UI/JavaScript/JsInitialize.js | 4 +- src/Umbraco.Web/Umbraco.Web.csproj | 29 ++- src/Umbraco.Web/UmbracoDefaultOwinStartup.cs | 23 +- src/Umbraco.Web/UmbracoHelper.cs | 44 +++- src/Umbraco.Web/UriUtility.cs | 2 +- .../WebApi/Binders/MemberBinder.cs | 56 +++- .../CheckIfUserTicketDataIsStaleAttribute.cs | 11 +- .../Filters/ContentItemValidationHelper.cs | 12 +- .../Filters/FeatureAuthorizeAttribute.cs | 23 ++ .../FilterAllowedOutgoingContentAttribute.cs | 29 +-- .../WebApi/UmbracoApiControllerBase.cs | 2 + .../ExamineManagementApiController.cs | 39 ++- .../_Legacy/UI/LegacyDialogHandler.cs | 10 +- 119 files changed, 3016 insertions(+), 1376 deletions(-) rename src/Umbraco.Web/{Strategies => Components}/DatabaseServerRegistrarAndMessengerComponent.cs (70%) rename src/Umbraco.Web/{Strategies => Components}/LegacyServerRegistrarAndMessengerComponent.cs (90%) rename src/Umbraco.Web/{Strategies => Components}/NotificationsComponent.cs (98%) rename src/Umbraco.Web/{Strategies => Components}/PublicAccessComponent.cs (97%) create mode 100644 src/Umbraco.Web/Editors/EditorModelEventArgs.cs create mode 100644 src/Umbraco.Web/Editors/HelpController.cs create mode 100644 src/Umbraco.Web/Editors/TourController.cs create mode 100644 src/Umbraco.Web/ExamineStartup.cs create mode 100644 src/Umbraco.Web/Models/BackOfficeTour.cs create mode 100644 src/Umbraco.Web/Models/BackOfficeTourFile.cs create mode 100644 src/Umbraco.Web/Models/BackOfficeTourFilter.cs create mode 100644 src/Umbraco.Web/Models/BackOfficeTourStep.cs create mode 100644 src/Umbraco.Web/Models/ContentEditing/EditorNavigation.cs create mode 100644 src/Umbraco.Web/Models/ContentEditing/PostedFolder.cs create mode 100644 src/Umbraco.Web/Models/Mapping/AuditMapperProfile.cs create mode 100644 src/Umbraco.Web/Models/Mapping/AutoMapperExtensions.cs create mode 100644 src/Umbraco.Web/Models/Mapping/ContentChildOfListViewResolver.cs create mode 100644 src/Umbraco.Web/Models/Mapping/ContentTreeNodeUrlResolver.cs create mode 100644 src/Umbraco.Web/Models/Mapping/ContentTypeBasicResolver.cs create mode 100644 src/Umbraco.Web/Models/Mapping/DefaultTemplateResolver.cs create mode 100644 src/Umbraco.Web/Models/Mapping/MediaChildOfListViewResolver.cs create mode 100644 src/Umbraco.Web/Models/Mapping/MemberBasicPropertiesResolver.cs create mode 100644 src/Umbraco.Web/Models/Mapping/MemberTreeNodeUrlResolver.cs create mode 100644 src/Umbraco.Web/Models/Mapping/MembershipUserTypeConverter.cs rename src/Umbraco.Web/Models/Mapping/{DashboardMapperProfile.cs => RedirectUrlMapperProfile.cs} (65%) create mode 100644 src/Umbraco.Web/Models/Trees/ExportMember.cs create mode 100644 src/Umbraco.Web/Models/UserTourStatus.cs create mode 100644 src/Umbraco.Web/Tour/BackOfficeTourFilter.cs create mode 100644 src/Umbraco.Web/Tour/TourFilterCollection.cs create mode 100644 src/Umbraco.Web/Tour/TourFilterCollectionBuilder.cs create mode 100644 src/Umbraco.Web/Trees/MacroTreeController.cs create mode 100644 src/Umbraco.Web/WebApi/Filters/FeatureAuthorizeAttribute.cs diff --git a/src/Umbraco.Core/Configuration/UmbracoVersion.cs b/src/Umbraco.Core/Configuration/UmbracoVersion.cs index c2ca568a54..a5fe8de270 100644 --- a/src/Umbraco.Core/Configuration/UmbracoVersion.cs +++ b/src/Umbraco.Core/Configuration/UmbracoVersion.cs @@ -1,4 +1,5 @@ using System; +using System.Configuration; using System.Reflection; using Semver; @@ -34,11 +35,37 @@ namespace Umbraco.Core.Configuration /// /// Gets the semantic version of the executing code. /// - public static SemVersion SemanticVersion => new SemVersion( - Current.Major, - Current.Minor, - Current.Build, - CurrentComment.IsNullOrWhiteSpace() ? null : CurrentComment, - Current.Revision > 0 ? Current.Revision.ToInvariantString() : null); + public static SemVersion SemanticVersion { get; } = new SemVersion( + Current.Major, + Current.Minor, + Current.Build, + CurrentComment.IsNullOrWhiteSpace() ? null : CurrentComment, + Current.Revision > 0 ? Current.Revision.ToInvariantString() : null); + + /// + /// Gets the "local" version of the site. + /// + /// + /// Three things have a version, really: the executing code, the database model, + /// and the site/files. The database model version is entirely managed via migrations, + /// and changes during an upgrade. The executing code version changes when new code is + /// deployed. The site/files version changes during an upgrade. + /// + public static SemVersion Local + { + get + { + try + { + // fixme - this should live in its own independent file! NOT web.config! + var value = ConfigurationManager.AppSettings["umbracoConfigurationStatus"]; + return SemVersion.TryParse(value, out var semver) ? semver : null; + } + catch + { + return null; + } + } + } } } diff --git a/src/Umbraco.Core/Runtime/CoreRuntime.cs b/src/Umbraco.Core/Runtime/CoreRuntime.cs index 41e2c771b7..7d28ffcf57 100644 --- a/src/Umbraco.Core/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Core/Runtime/CoreRuntime.cs @@ -235,14 +235,14 @@ namespace Umbraco.Core.Runtime private void SetRuntimeStateLevel(RuntimeState runtimeState, IUmbracoDatabaseFactory databaseFactory, ILogger logger) { - var localVersion = LocalVersion; // the local, files, version + var localVersion = UmbracoVersion.Local; // the local, files, version var codeVersion = runtimeState.SemanticVersion; // the executing code version var connect = false; // we don't know yet runtimeState.Level = RuntimeLevel.Unknown; - if (string.IsNullOrWhiteSpace(localVersion)) + if (localVersion == null) { // there is no local version, we are not installed logger.Debug("No local version, need to install Umbraco."); @@ -350,22 +350,6 @@ namespace Umbraco.Core.Runtime return state == finalState; } - private static string LocalVersion - { - get - { - try - { - // fixme - this should live in its own independent file! NOT web.config! - return ConfigurationManager.AppSettings["umbracoConfigurationStatus"]; - } - catch - { - return string.Empty; - } - } - } - #region Locals protected ILogger Logger { get; private set; } diff --git a/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs b/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs index cf545d0687..2bdad031de 100644 --- a/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs +++ b/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs @@ -43,7 +43,7 @@ namespace Umbraco.Core.Sync private bool _syncing; private bool _released; - protected DatabaseServerMessengerOptions Options { get; } + public DatabaseServerMessengerOptions Options { get; } public DatabaseServerMessenger( IRuntimeState runtime, IScopeProvider scopeProvider, ISqlContext sqlContext, ILogger logger, ProfilingLogger proflog, @@ -86,8 +86,7 @@ namespace Umbraco.Core.Sync { var idsA = ids?.ToArray(); - Type idType; - if (GetArrayType(idsA, out idType) == false) + if (GetArrayType(idsA, out var idType) == false) throw new ArgumentException("All items must be of the same type, either int or Guid.", nameof(ids)); var instructions = RefreshInstruction.GetInstructions(refresher, messageType, idsA, idType, json); diff --git a/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs b/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs index 09b6b72478..b6ca5912e2 100644 --- a/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs +++ b/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs @@ -14,7 +14,7 @@ namespace Umbraco.Core.Sync /// /// Gets or sets the registrar options. /// - public DatabaseServerRegistrarOptions Options { get; private set; } + public DatabaseServerRegistrarOptions Options { get; } /// /// Initializes a new instance of the class. @@ -23,20 +23,14 @@ namespace Umbraco.Core.Sync /// Some options. public DatabaseServerRegistrar(Lazy registrationService, DatabaseServerRegistrarOptions options) { - if (registrationService == null) throw new ArgumentNullException("registrationService"); - if (options == null) throw new ArgumentNullException("options"); - - Options = options; - _registrationService = registrationService; + Options = options ?? throw new ArgumentNullException(nameof(options)); + _registrationService = registrationService ?? throw new ArgumentNullException(nameof(registrationService)); } /// /// Gets the registered servers. /// - public IEnumerable Registrations - { - get { return _registrationService.Value.GetActiveServers(); } - } + public IEnumerable Registrations => _registrationService.Value.GetActiveServers(); /// /// Gets the role of the current server in the application environment. diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 6a19b00f22..e4a2d0c9bb 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -59,7 +59,7 @@ - 1.9.2 + 1.9.6 diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 4c8df3cc87..650ed00f0b 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -54,6 +54,9 @@ ..\packages\AutoMapper.6.1.1\lib\net45\AutoMapper.dll + + ..\packages\ClientDependency.1.9.6\lib\net45\ClientDependency.Core.dll + ..\packages\ImageProcessor.Web.4.8.4\lib\net45\ImageProcessor.Web.dll @@ -254,10 +257,6 @@ - - ..\packages\ClientDependency.1.9.2\lib\net45\ClientDependency.Core.dll - True - ../packages/log4net.2.0.8/lib/net45-full/log4net.dll True diff --git a/src/Umbraco.Web.UI/packages.config b/src/Umbraco.Web.UI/packages.config index 37bc562410..1b519c30ad 100644 --- a/src/Umbraco.Web.UI/packages.config +++ b/src/Umbraco.Web.UI/packages.config @@ -1,7 +1,7 @@  - + diff --git a/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs b/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs index ac61dd790d..2dfc30e37d 100644 --- a/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs +++ b/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Web; using Newtonsoft.Json; -using NPoco; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Sync; @@ -39,7 +38,6 @@ namespace Umbraco.Web internal void Startup() { UmbracoModule.EndRequest += UmbracoModule_EndRequest; - UmbracoModule.RouteAttempt += UmbracoModule_RouteAttempt; if (_databaseFactory.CanConnect == false) { @@ -52,23 +50,6 @@ namespace Umbraco.Web } } - private void UmbracoModule_RouteAttempt(object sender, RoutableAttemptEventArgs e) - { - // as long as umbraco is ready & configured, sync - switch (e.Outcome) - { - case EnsureRoutableOutcome.IsRoutable: - case EnsureRoutableOutcome.NotDocumentRequest: - case EnsureRoutableOutcome.NoContent: - Sync(); - break; - //case EnsureRoutableOutcome.NotReady: - //case EnsureRoutableOutcome.NotConfigured: - //default: - // break; - } - } - private void UmbracoModule_EndRequest(object sender, UmbracoRequestEventArgs e) { // will clear the batch - will remain in HttpContext though - that's ok @@ -108,7 +89,8 @@ namespace Umbraco.Web { UtcStamp = DateTime.UtcNow, Instructions = JsonConvert.SerializeObject(instructions, Formatting.None), - OriginIdentity = LocalIdentity + OriginIdentity = LocalIdentity, + InstructionCount = instructions.Sum(x => x.JsonIdCount) }; using (var scope = ScopeProvider.CreateScope()) diff --git a/src/Umbraco.Web/Cache/CacheRefresherComponent.cs b/src/Umbraco.Web/Cache/CacheRefresherComponent.cs index ef1665e3c2..9d912283a6 100644 --- a/src/Umbraco.Web/Cache/CacheRefresherComponent.cs +++ b/src/Umbraco.Web/Cache/CacheRefresherComponent.cs @@ -32,7 +32,7 @@ namespace Umbraco.Web.Cache [RequiredComponent(typeof(IUmbracoCoreComponent))] // runs before every other IUmbracoCoreComponent! public class CacheRefresherComponent : UmbracoComponentBase, IUmbracoCoreComponent { - private static readonly ConcurrentDictionary FoundHandlers = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary FoundHandlers = new ConcurrentDictionary(); private DistributedCache _distributedCache; private List _unbinders; @@ -195,7 +195,7 @@ namespace Umbraco.Web.Cache { var name = eventDefinition.Sender.GetType().Name + "_" + eventDefinition.EventName; - return FoundHandlers.GetOrAdd(eventDefinition, _ => CandidateHandlers.Value.FirstOrDefault(x => x.Name == name)); + return FoundHandlers.GetOrAdd(name, n => CandidateHandlers.Value.FirstOrDefault(x => x.Name == n)); } private static readonly Lazy CandidateHandlers = new Lazy(() => @@ -480,10 +480,10 @@ namespace Umbraco.Web.Cache _distributedCache.RemoveUserCache(entity.Id); } - private void UserService_SavedUserGroup(IUserService sender, SaveEventArgs e) + private void UserService_SavedUserGroup(IUserService sender, SaveEventArgs e) { foreach (var entity in e.SavedEntities) - _distributedCache.RefreshUserGroupCache(entity.Id); + _distributedCache.RefreshUserGroupCache(entity.UserGroup.Id); } private void UserService_DeletedUserGroup(IUserService sender, DeleteEventArgs e) diff --git a/src/Umbraco.Web/Cache/MemberGroupCacheRefresher.cs b/src/Umbraco.Web/Cache/MemberGroupCacheRefresher.cs index b23cdb5b5a..4540f0b495 100644 --- a/src/Umbraco.Web/Cache/MemberGroupCacheRefresher.cs +++ b/src/Umbraco.Web/Cache/MemberGroupCacheRefresher.cs @@ -1,24 +1,16 @@ using System; using System.Linq; using Newtonsoft.Json; -using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Models; -using Umbraco.Core.Persistence.Repositories; -using Umbraco.Core.Persistence.Repositories.Implement; -using Umbraco.Core.Services; namespace Umbraco.Web.Cache { public sealed class MemberGroupCacheRefresher : JsonCacheRefresherBase { - private readonly IMemberGroupService _memberGroupService; - - public MemberGroupCacheRefresher(CacheHelper cacheHelper, IMemberGroupService memberGroupService) + public MemberGroupCacheRefresher(CacheHelper cacheHelper) : base(cacheHelper) - { - _memberGroupService = memberGroupService; - } + { } #region Define @@ -36,39 +28,28 @@ namespace Umbraco.Web.Cache public override void Refresh(string json) { - var payload = Deserialize(json); - ClearCache(payload); + ClearCache(); base.Refresh(json); } public override void Refresh(int id) { - var group = _memberGroupService.GetById(id); - if (group != null) - ClearCache(new JsonPayload(group.Id, group.Name)); + ClearCache(); base.Refresh(id); } public override void Remove(int id) { - var group = _memberGroupService.GetById(id); - if (group != null) - ClearCache(new JsonPayload(group.Id, group.Name)); + ClearCache(); base.Remove(id); } - private void ClearCache(params JsonPayload[] payloads) + private void ClearCache() { - if (payloads == null) return; - - var memberGroupCache = CacheHelper.IsolatedRuntimeCache.GetCache(); - if (memberGroupCache == false) return; - - foreach (var payload in payloads.WhereNotNull()) - { - memberGroupCache.Result.ClearCacheByKeySearch($"{typeof(IMemberGroup).FullName}.{payload.Name}"); - memberGroupCache.Result.ClearCacheItem(RepositoryCacheKeys.GetKey(payload.Id)); - } + // Since we cache by group name, it could be problematic when renaming to + // previously existing names - see http://issues.umbraco.org/issue/U4-10846. + // To work around this, just clear all the cache items + CacheHelper.IsolatedRuntimeCache.ClearCache(); } #endregion diff --git a/src/Umbraco.Web/Strategies/DatabaseServerRegistrarAndMessengerComponent.cs b/src/Umbraco.Web/Components/DatabaseServerRegistrarAndMessengerComponent.cs similarity index 70% rename from src/Umbraco.Web/Strategies/DatabaseServerRegistrarAndMessengerComponent.cs rename to src/Umbraco.Web/Components/DatabaseServerRegistrarAndMessengerComponent.cs index 14f83c44eb..2048ba9e58 100644 --- a/src/Umbraco.Web/Strategies/DatabaseServerRegistrarAndMessengerComponent.cs +++ b/src/Umbraco.Web/Components/DatabaseServerRegistrarAndMessengerComponent.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading; -using System.Threading.Tasks; using Examine; +using LightInject; using Umbraco.Core; using Umbraco.Core.Components; using Umbraco.Core.Configuration; @@ -14,13 +14,11 @@ using Umbraco.Core.Services; using Umbraco.Core.Services.Changes; using Umbraco.Core.Sync; using Umbraco.Web.Cache; +using Umbraco.Web.Composing; using Umbraco.Web.Routing; using Umbraco.Web.Scheduling; -using LightInject; -using Umbraco.Core.Exceptions; -using Umbraco.Web.Composing; -namespace Umbraco.Web.Strategies +namespace Umbraco.Web.Components { /// /// Ensures that servers are automatically registered in the database, when using the database server registrar. @@ -38,13 +36,14 @@ namespace Umbraco.Web.Strategies { private object _locker = new object(); private DatabaseServerRegistrar _registrar; + private BatchedDatabaseServerMessenger _messenger; private IRuntimeState _runtime; private ILogger _logger; private IServerRegistrationService _registrationService; - private BackgroundTaskRunner _backgroundTaskRunner; + private BackgroundTaskRunner _touchTaskRunner; + private BackgroundTaskRunner _processTaskRunner; private bool _started; - private TouchServerTask _task; - private IUmbracoDatabaseFactory _databaseFactory; + private IBackgroundTask[] _tasks; public override void Compose(Composition composition) { @@ -102,24 +101,27 @@ namespace Umbraco.Web.Strategies indexer.Value.RebuildIndex(); } - public void Initialize(IRuntimeState runtime, IServerRegistrar serverRegistrar, IServerRegistrationService registrationService, IUmbracoDatabaseFactory databaseFactory, ILogger logger) + public void Initialize(IRuntimeState runtime, IServerRegistrar serverRegistrar, IServerMessenger serverMessenger, IServerRegistrationService registrationService, ILogger logger) { if (UmbracoConfig.For.UmbracoSettings().DistributedCall.Enabled) return; _registrar = serverRegistrar as DatabaseServerRegistrar; if (_registrar == null) throw new Exception("panic: registar."); + _messenger = serverMessenger as BatchedDatabaseServerMessenger; + if (_messenger == null) throw new Exception("panic: messenger"); + _runtime = runtime; - _databaseFactory = databaseFactory; _logger = logger; _registrationService = registrationService; - _backgroundTaskRunner = new BackgroundTaskRunner( - new BackgroundTaskRunnerOptions { AutoStart = true }, - logger); + _touchTaskRunner = new BackgroundTaskRunner("ServerRegistration", + new BackgroundTaskRunnerOptions { AutoStart = true }, logger); + _processTaskRunner = new BackgroundTaskRunner("ServerInstProcess", + new BackgroundTaskRunnerOptions { AutoStart = true }, logger); //We will start the whole process when a successful request is made - UmbracoModule.RouteAttempt += UmbracoModuleRouteAttempt; + UmbracoModule.RouteAttempt += RegisterBackgroundTasksOnce; } /// @@ -133,70 +135,95 @@ namespace Umbraco.Web.Strategies /// - RegisterServer is called on UmbracoModule.RouteAttempt which is triggered in ProcessRequest /// we are safe, UmbracoApplicationUrl has been initialized /// - private void UmbracoModuleRouteAttempt(object sender, RoutableAttemptEventArgs e) + private void RegisterBackgroundTasksOnce(object sender, RoutableAttemptEventArgs e) { switch (e.Outcome) { case EnsureRoutableOutcome.IsRoutable: case EnsureRoutableOutcome.NotDocumentRequest: - RegisterBackgroundTasks(e); + UmbracoModule.RouteAttempt -= RegisterBackgroundTasksOnce; + RegisterBackgroundTasks(); break; } } - private void RegisterBackgroundTasks(UmbracoRequestEventArgs e) + private void RegisterBackgroundTasks() { - // remove handler, we're done - UmbracoModule.RouteAttempt -= UmbracoModuleRouteAttempt; - // only perform this one time ever - LazyInitializer.EnsureInitialized(ref _task, ref _started, ref _locker, () => + LazyInitializer.EnsureInitialized(ref _tasks, ref _started, ref _locker, () => { var serverAddress = _runtime.ApplicationUrl.ToString(); - var svc = _registrationService; - var task = new TouchServerTask(_backgroundTaskRunner, - 15000, //delay before first execution - _registrar.Options.RecurringSeconds*1000, //amount of ms between executions - svc, _registrar, serverAddress, _databaseFactory, _logger); - - // perform the rest async, we don't want to block the startup sequence - // this will just reoccur on a background thread - _backgroundTaskRunner.TryAdd(task); - - return task; + return new[] + { + RegisterInstructionProcess(), + RegisterTouchServer(_registrationService, serverAddress) + }; }); } + private IBackgroundTask RegisterInstructionProcess() + { + var task = new InstructionProcessTask(_processTaskRunner, + 60000, //delay before first execution + _messenger.Options.ThrottleSeconds*1000, //amount of ms between executions + _messenger); + _processTaskRunner.TryAdd(task); + return task; + } + + private IBackgroundTask RegisterTouchServer(IServerRegistrationService registrationService, string serverAddress) + { + var task = new TouchServerTask(_touchTaskRunner, + 15000, //delay before first execution + _registrar.Options.RecurringSeconds*1000, //amount of ms between executions + registrationService, _registrar, serverAddress, _logger); + _touchTaskRunner.TryAdd(task); + return task; + } + + private class InstructionProcessTask : RecurringTaskBase + { + private readonly DatabaseServerMessenger _messenger; + + public InstructionProcessTask(IBackgroundTaskRunner runner, int delayMilliseconds, int periodMilliseconds, + DatabaseServerMessenger messenger) + : base(runner, delayMilliseconds, periodMilliseconds) + { + _messenger = messenger; + } + + public override bool IsAsync => false; + + /// + /// Runs the background task. + /// + /// A value indicating whether to repeat the task. + public override bool PerformRun() + { + // TODO what happens in case of an exception? + _messenger.Sync(); + return true; // repeat + } + } + private class TouchServerTask : RecurringTaskBase { private readonly IServerRegistrationService _svc; private readonly DatabaseServerRegistrar _registrar; private readonly string _serverAddress; - private readonly IUmbracoDatabaseFactory _databaseFactory; private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The task runner. - /// The delay. - /// The period. - /// - /// - /// - /// - /// - /// The task will repeat itself periodically. Use this constructor to create a new task. public TouchServerTask(IBackgroundTaskRunner runner, int delayMilliseconds, int periodMilliseconds, - IServerRegistrationService svc, DatabaseServerRegistrar registrar, string serverAddress, IUmbracoDatabaseFactory databaseFactory, ILogger logger) + IServerRegistrationService svc, DatabaseServerRegistrar registrar, string serverAddress, ILogger logger) : base(runner, delayMilliseconds, periodMilliseconds) { - if (svc == null) throw new ArgumentNullException(nameof(svc)); - _svc = svc; + _svc = svc ?? throw new ArgumentNullException(nameof(svc)); _registrar = registrar; _serverAddress = serverAddress; - _databaseFactory = databaseFactory; _logger = logger; } diff --git a/src/Umbraco.Web/Strategies/LegacyServerRegistrarAndMessengerComponent.cs b/src/Umbraco.Web/Components/LegacyServerRegistrarAndMessengerComponent.cs similarity index 90% rename from src/Umbraco.Web/Strategies/LegacyServerRegistrarAndMessengerComponent.cs rename to src/Umbraco.Web/Components/LegacyServerRegistrarAndMessengerComponent.cs index 327be338a5..d494d239a3 100644 --- a/src/Umbraco.Web/Strategies/LegacyServerRegistrarAndMessengerComponent.cs +++ b/src/Umbraco.Web/Components/LegacyServerRegistrarAndMessengerComponent.cs @@ -1,15 +1,19 @@ using System; +using LightInject; using Umbraco.Core; using Umbraco.Core.Components; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Services; -using LightInject; using Umbraco.Web.Runtime; -namespace Umbraco.Web.Strategies +namespace Umbraco.Web.Components { + // the legacy LB is not enabled by default, because LB is implemented by + // DatabaseServerRegistrarAndMessengerComponent instead + [RuntimeLevel(MinLevel = RuntimeLevel.Run)] + [DisableComponent] // is not enabled by default public sealed class LegacyServerRegistrarAndMessengerComponent : UmbracoComponentBase, IUmbracoCoreComponent { public override void Compose(Composition composition) diff --git a/src/Umbraco.Web/Strategies/NotificationsComponent.cs b/src/Umbraco.Web/Components/NotificationsComponent.cs similarity index 98% rename from src/Umbraco.Web/Strategies/NotificationsComponent.cs rename to src/Umbraco.Web/Components/NotificationsComponent.cs index b56fb75df0..292bb4bf58 100644 --- a/src/Umbraco.Web/Strategies/NotificationsComponent.cs +++ b/src/Umbraco.Web/Components/NotificationsComponent.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; using Umbraco.Core; -using Umbraco.Core.Services; using Umbraco.Core.Components; -using Umbraco.Web._Legacy.Actions; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; +using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; +using Umbraco.Web._Legacy.Actions; -namespace Umbraco.Web.Strategies +namespace Umbraco.Web.Components { [RuntimeLevel(MinLevel = RuntimeLevel.Run)] public sealed class NotificationsComponent : UmbracoComponentBase, IUmbracoCoreComponent diff --git a/src/Umbraco.Web/Strategies/PublicAccessComponent.cs b/src/Umbraco.Web/Components/PublicAccessComponent.cs similarity index 97% rename from src/Umbraco.Web/Strategies/PublicAccessComponent.cs rename to src/Umbraco.Web/Components/PublicAccessComponent.cs index 87bab76ebb..5c18eefc66 100644 --- a/src/Umbraco.Web/Strategies/PublicAccessComponent.cs +++ b/src/Umbraco.Web/Components/PublicAccessComponent.cs @@ -4,7 +4,7 @@ using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; using Umbraco.Web.Composing; -namespace Umbraco.Web.Strategies +namespace Umbraco.Web.Components { /// /// Used to ensure that the public access data file is kept up to date properly diff --git a/src/Umbraco.Web/Composing/CompositionRoots/WebMappingProfilesCompositionRoot.cs b/src/Umbraco.Web/Composing/CompositionRoots/WebMappingProfilesCompositionRoot.cs index 5ac4904712..56b61f1fc9 100644 --- a/src/Umbraco.Web/Composing/CompositionRoots/WebMappingProfilesCompositionRoot.cs +++ b/src/Umbraco.Web/Composing/CompositionRoots/WebMappingProfilesCompositionRoot.cs @@ -19,8 +19,9 @@ namespace Umbraco.Web.Composing.CompositionRoots container.Register(); container.Register(); container.Register(); - container.Register(); container.Register(); + container.Register(); + container.Register(); } } } diff --git a/src/Umbraco.Web/Controllers/UmbRegisterController.cs b/src/Umbraco.Web/Controllers/UmbRegisterController.cs index 0d622b8730..64f54dd465 100644 --- a/src/Umbraco.Web/Controllers/UmbRegisterController.cs +++ b/src/Umbraco.Web/Controllers/UmbRegisterController.cs @@ -17,6 +17,13 @@ namespace Umbraco.Web.Controllers return CurrentUmbracoPage(); } + // U4-10762 Server error with "Register Member" snippet (Cannot save member with empty name) + // If name field is empty, add the email address instead + if (string.IsNullOrEmpty(model.Name) && string.IsNullOrEmpty(model.Email) == false) + { + model.Name = model.Email; + } + MembershipCreateStatus status; var member = Members.RegisterMember(model, out status, model.LoginOnSuccess); diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index 10ec705b74..e15c371d20 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Collections.Generic; +using System.Security.Principal; using System.Threading.Tasks; using System.Web; using System.Web.Http; @@ -214,6 +215,7 @@ namespace Umbraco.Web.Editors public async Task PostLogin(LoginModel loginModel) { var http = EnsureHttpContext(); + var owinContext = TryGetOwinContext().Result; //Sign the user in with username/password, this also gives a chance for developers to //custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker @@ -228,7 +230,7 @@ namespace Umbraco.Web.Editors var user = Services.UserService.GetByUsername(loginModel.Username); UserManager.RaiseLoginSuccessEvent(user.Id); - return SetPrincipalAndReturnUserDetail(user); + return SetPrincipalAndReturnUserDetail(user, owinContext.Request.User); case SignInStatus.RequiresVerification: var twofactorOptions = UserManager as IUmbracoBackOfficeTwoFactorOptions; @@ -241,7 +243,7 @@ namespace Umbraco.Web.Editors } var twofactorView = twofactorOptions.GetTwoFactorView( - TryGetOwinContext().Result, + owinContext, UmbracoContext, loginModel.Username); @@ -372,13 +374,14 @@ namespace Umbraco.Web.Editors } var result = await SignInManager.TwoFactorSignInAsync(model.Provider, model.Code, isPersistent: true, rememberBrowser: false); + var owinContext = TryGetOwinContext().Result; var user = Services.UserService.GetByUsername(userName); switch (result) { case SignInStatus.Success: UserManager.RaiseLoginSuccessEvent(user.Id); - return SetPrincipalAndReturnUserDetail(user); + return SetPrincipalAndReturnUserDetail(user, owinContext.Request.User); case SignInStatus.LockedOut: UserManager.RaiseAccountLockedEvent(user.Id); return Request.CreateValidationErrorResponse("User is locked out"); @@ -437,13 +440,15 @@ namespace Umbraco.Web.Editors [ValidateAngularAntiForgeryToken] public HttpResponseMessage PostLogout() { - Request.TryGetOwinContext().Result.Authentication.SignOut( + var owinContext = Request.TryGetOwinContext().Result; + + owinContext.Authentication.SignOut( Core.Constants.Security.BackOfficeAuthenticationType, Core.Constants.Security.BackOfficeExternalAuthenticationType); Logger.Info("User {0} from IP address {1} has logged out", () => User.Identity == null ? "UNKNOWN" : User.Identity.Name, - () => TryGetOwinContext().Result.Request.RemoteIpAddress); + () => owinContext.Request.RemoteIpAddress); if (UserManager != null) { @@ -459,10 +464,12 @@ namespace Umbraco.Web.Editors /// This is used when the user is auth'd successfully and we need to return an OK with user details along with setting the current Principal in the request /// /// + /// /// - private HttpResponseMessage SetPrincipalAndReturnUserDetail(IUser user) + private HttpResponseMessage SetPrincipalAndReturnUserDetail(IUser user, IPrincipal principal) { if (user == null) throw new ArgumentNullException("user"); + if (principal == null) throw new ArgumentNullException(nameof(principal)); var userDetail = Mapper.Map(user); //update the userDetail and set their remaining seconds @@ -472,7 +479,7 @@ namespace Umbraco.Web.Editors var response = Request.CreateResponse(HttpStatusCode.OK, userDetail); //ensure the user is set for the current request - Request.SetPrincipalForRequest(user); + Request.SetPrincipalForRequest(principal); return response; } diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index d1c3009792..b31e870ca4 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -375,6 +375,19 @@ namespace Umbraco.Web.Editors if (loginInfo == null) throw new ArgumentNullException("loginInfo"); if (response == null) throw new ArgumentNullException("response"); + + //Here we can check if the provider associated with the request has been configured to allow + // new users (auto-linked external accounts). This would never be used with public providers such as + // Google, unless you for some reason wanted anybody to be able to access the backend if they have a Google account + // .... not likely! + var authType = OwinContext.Authentication.GetExternalAuthenticationTypes().FirstOrDefault(x => x.AuthenticationType == loginInfo.Login.LoginProvider); + if (authType == null) + { + Logger.Warn("Could not find external authentication provider registered: " + loginInfo.Login.LoginProvider); + } + + var autoLinkOptions = authType.GetExternalAuthenticationOptions(); + // Sign in the user with this external login provider if the user already has a login var user = await UserManager.FindAsync(loginInfo.Login); if (user != null) @@ -385,12 +398,25 @@ namespace Umbraco.Web.Editors // ticket format, etc.. to create our back office user including the claims assigned and in this method we'd just ensure // that the ticket is created and stored and that the user is logged in. - //sign in - await SignInManager.SignInAsync(user, isPersistent: false, rememberBrowser: false); + var shouldSignIn = true; + if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) + { + shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); + if (shouldSignIn == false) + { + Logger.Warn("The AutoLinkOptions of the external authentication provider '" + loginInfo.Login.LoginProvider + "' have refused the login based on the OnExternalLogin method. Affected user id: '" + user.Id + "'"); + } + } + + if (shouldSignIn) + { + //sign in + await SignInManager.SignInAsync(user, isPersistent: false, rememberBrowser: false); + } } else { - if (await AutoLinkAndSignInExternalAccount(loginInfo) == false) + if (await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions) == false) { ViewData[TokenExternalSignInError] = new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not been linked to to an account" }; } @@ -405,97 +431,81 @@ namespace Umbraco.Web.Editors return response(); } - private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo) + private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions) { - //Here we can check if the provider associated with the request has been configured to allow - // new users (auto-linked external accounts). This would never be used with public providers such as - // Google, unless you for some reason wanted anybody to be able to access the backend if they have a Google account - // .... not likely! - - var authType = OwinContext.Authentication.GetExternalAuthenticationTypes().FirstOrDefault(x => x.AuthenticationType == loginInfo.Login.LoginProvider); - if (authType == null) - { - Logger.Warn("Could not find external authentication provider registered: " + loginInfo.Login.LoginProvider); + if (autoLinkOptions == null) return false; - } - var autoLinkOptions = authType.GetExternalAuthenticationOptions(); - if (autoLinkOptions != null) + if (autoLinkOptions.ShouldAutoLinkExternalAccount(UmbracoContext, loginInfo) == false) + return true; + + //we are allowing auto-linking/creating of local accounts + if (loginInfo.Email.IsNullOrWhiteSpace()) { - if (autoLinkOptions.ShouldAutoLinkExternalAccount(UmbracoContext, loginInfo)) + ViewData[TokenExternalSignInError] = new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not provided an email address, the account cannot be linked." }; + } + else + { + //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address + var foundByEmail = Services.UserService.GetByEmail(loginInfo.Email); + if (foundByEmail != null) { - //we are allowing auto-linking/creating of local accounts - if (loginInfo.Email.IsNullOrWhiteSpace()) + ViewData[TokenExternalSignInError] = new[] { "A user with this email address already exists locally. You will need to login locally to Umbraco and link this external provider: " + loginInfo.Login.LoginProvider }; + } + else + { + if (loginInfo.Email.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Email value cannot be null"); + if (loginInfo.ExternalIdentity.Name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null"); + + var groups = Services.UserService.GetUserGroupsByAlias(autoLinkOptions.GetDefaultUserGroups(UmbracoContext, loginInfo)); + + var autoLinkUser = BackOfficeIdentityUser.CreateNew( + loginInfo.Email, + loginInfo.Email, + autoLinkOptions.GetDefaultCulture(UmbracoContext, loginInfo)); + autoLinkUser.Name = loginInfo.ExternalIdentity.Name; + foreach (var userGroup in groups) { - ViewData[TokenExternalSignInError] = new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not provided an email address, the account cannot be linked." }; + autoLinkUser.AddRole(userGroup.Alias); + } + + //call the callback if one is assigned + if (autoLinkOptions.OnAutoLinking != null) + { + autoLinkOptions.OnAutoLinking(autoLinkUser, loginInfo); + } + + var userCreationResult = await UserManager.CreateAsync(autoLinkUser); + + if (userCreationResult.Succeeded == false) + { + ViewData[TokenExternalSignInError] = userCreationResult.Errors; } else { - - //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address - var foundByEmail = Services.UserService.GetByEmail(loginInfo.Email); - if (foundByEmail != null) + var linkResult = await UserManager.AddLoginAsync(autoLinkUser.Id, loginInfo.Login); + if (linkResult.Succeeded == false) { - ViewData[TokenExternalSignInError] = new[] { "A user with this email address already exists locally. You will need to login locally to Umbraco and link this external provider: " + loginInfo.Login.LoginProvider }; + ViewData[TokenExternalSignInError] = linkResult.Errors; + + //If this fails, we should really delete the user since it will be in an inconsistent state! + var deleteResult = await UserManager.DeleteAsync(autoLinkUser); + if (deleteResult.Succeeded == false) + { + //DOH! ... this isn't good, combine all errors to be shown + ViewData[TokenExternalSignInError] = linkResult.Errors.Concat(deleteResult.Errors); + } } else { - if (loginInfo.Email.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Email value cannot be null"); - if (loginInfo.ExternalIdentity.Name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null"); - - var groups = Services.UserService.GetUserGroupsByAlias(autoLinkOptions.GetDefaultUserGroups(UmbracoContext, loginInfo)); - - var autoLinkUser = BackOfficeIdentityUser.CreateNew( - loginInfo.Email, - loginInfo.Email, - autoLinkOptions.GetDefaultCulture(UmbracoContext, loginInfo)); - autoLinkUser.Name = loginInfo.ExternalIdentity.Name; - foreach (var userGroup in groups) - { - autoLinkUser.AddRole(userGroup.Alias); - } - - //call the callback if one is assigned - if (autoLinkOptions.OnAutoLinking != null) - { - autoLinkOptions.OnAutoLinking(autoLinkUser, loginInfo); - } - - var userCreationResult = await UserManager.CreateAsync(autoLinkUser); - - if (userCreationResult.Succeeded == false) - { - ViewData[TokenExternalSignInError] = userCreationResult.Errors; - } - else - { - var linkResult = await UserManager.AddLoginAsync(autoLinkUser.Id, loginInfo.Login); - if (linkResult.Succeeded == false) - { - ViewData[TokenExternalSignInError] = linkResult.Errors; - - //If this fails, we should really delete the user since it will be in an inconsistent state! - var deleteResult = await UserManager.DeleteAsync(autoLinkUser); - if (deleteResult.Succeeded == false) - { - //DOH! ... this isn't good, combine all errors to be shown - ViewData[TokenExternalSignInError] = linkResult.Errors.Concat(deleteResult.Errors); - } - } - else - { - //sign in - await SignInManager.SignInAsync(autoLinkUser, isPersistent: false, rememberBrowser: false); - } - } + //sign in + await SignInManager.SignInAsync(autoLinkUser, isPersistent: false, rememberBrowser: false); } - } } - return true; - } - return false; + } + return true; } /// diff --git a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs index c4a893235c..6d080ac048 100644 --- a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs @@ -118,6 +118,10 @@ namespace Umbraco.Web.Editors "redirectUrlManagementApiBaseUrl", _urlHelper.GetUmbracoApiServiceBaseUrl( controller => controller.GetEnableState()) }, + { + "tourApiBaseUrl", _urlHelper.GetUmbracoApiServiceBaseUrl( + controller => controller.GetTours()) + }, { "embedApiBaseUrl", _urlHelper.GetUmbracoApiServiceBaseUrl( controller => controller.GetEmbed("", 0, 0)) @@ -269,6 +273,10 @@ namespace Umbraco.Web.Editors { "nuCacheStatusBaseUrl", _urlHelper.GetUmbracoApiServiceBaseUrl( controller => controller.GetStatus()) + }, + { + "helpApiBaseUrl", _urlHelper.GetUmbracoApiServiceBaseUrl( + controller => controller.GetContextHelpForPage("","","")) } } }, @@ -378,9 +386,6 @@ namespace Umbraco.Web.Editors /// private Dictionary GetApplicationState() { - if (_runtimeState.Level != RuntimeLevel.Run) - return null; - var app = new Dictionary { {"assemblyVersion", UmbracoVersion.AssemblyVersion} @@ -388,7 +393,9 @@ namespace Umbraco.Web.Editors var version = _runtimeState.SemanticVersion.ToSemanticString(); - app.Add("cacheBuster", $"{version}.{ClientDependencySettings.Instance.Version}".GenerateHash()); + //the value is the hash of the version, cdf version and the configured state + app.Add("cacheBuster", $"{version}.{_runtimeState.Level}.{ClientDependencySettings.Instance.Version}".GenerateHash()); + app.Add("version", version); //useful for dealing with virtual paths on the client side when hosted in virtual directories especially diff --git a/src/Umbraco.Web/Editors/ContentController.cs b/src/Umbraco.Web/Editors/ContentController.cs index f8bf6b083c..6cae27bb3d 100644 --- a/src/Umbraco.Web/Editors/ContentController.cs +++ b/src/Umbraco.Web/Editors/ContentController.cs @@ -71,7 +71,7 @@ namespace Umbraco.Web.Editors public IEnumerable GetByIds([FromUri]int[] ids) { var foundContent = Services.ContentService.GetByIds(ids); - return foundContent.Select(Mapper.Map); + return foundContent.Select(x => ContextMapper.Map(x, UmbracoContext)); } /// @@ -223,7 +223,7 @@ namespace Umbraco.Web.Editors HandleContentNotFound(id); } - var content = Mapper.Map(foundContent); + var content = ContextMapper.Map(foundContent, UmbracoContext); SetupBlueprint(content, foundContent); @@ -261,7 +261,7 @@ namespace Umbraco.Web.Editors HandleContentNotFound(id); } - var content = Mapper.Map(foundContent); + var content = ContextMapper.Map(foundContent, UmbracoContext); return content; } @@ -274,7 +274,7 @@ namespace Umbraco.Web.Editors HandleContentNotFound(id); } - var content = Mapper.Map(foundContent); + var content = ContextMapper.Map(foundContent, UmbracoContext); return content; } @@ -297,7 +297,7 @@ namespace Umbraco.Web.Editors } var emptyContent = Services.ContentService.Create("", parentId, contentType.Alias, Security.GetUserId().ResultOr(0)); - var mapped = Mapper.Map(emptyContent); + var mapped = ContextMapper.Map(emptyContent, UmbracoContext); //remove this tab if it exists: umbContainerView var containerTab = mapped.Tabs.FirstOrDefault(x => x.Alias == Constants.Conventions.PropertyGroups.ListViewGroupName); @@ -433,9 +433,17 @@ namespace Umbraco.Web.Editors [HttpPost] public Dictionary GetPermissions(int[] nodeIds) { - return Services.UserService - .GetPermissions(Security.CurrentUser, nodeIds) - .ToDictionary(x => x.EntityId, x => x.AssignedPermissions); + var permissions = Services.UserService + .GetPermissions(Security.CurrentUser, nodeIds); + + var permissionsDictionary = new Dictionary(); + foreach (var nodeId in nodeIds) + { + var aggregatePerms = permissions.GetAllPermissions(nodeId).ToArray(); + permissionsDictionary.Add(nodeId, aggregatePerms); + } + + return permissionsDictionary; } /// @@ -525,6 +533,7 @@ namespace Umbraco.Web.Editors /// [FileUploadCleanupFilter] [ContentPostValidate] + [OutgoingEditorModelEvent] public ContentItemDisplay PostSave([ModelBinder(typeof(ContentItemBinder))] ContentItemSave contentItem) { return PostSaveInternal(contentItem, content => Services.ContentService.Save(contentItem.PersistedContent, Security.CurrentUser.Id)); @@ -552,7 +561,7 @@ namespace Umbraco.Web.Editors { //ok, so the absolute mandatory data is invalid and it's new, we cannot actually continue! // add the modelstate to the outgoing object and throw a validation message - var forDisplay = Mapper.Map(contentItem.PersistedContent); + var forDisplay = ContextMapper.Map(contentItem.PersistedContent, UmbracoContext); forDisplay.Errors = ModelState.ToErrorDictionary(); throw new HttpResponseException(Request.CreateValidationErrorResponse(forDisplay)); @@ -595,7 +604,7 @@ namespace Umbraco.Web.Editors } //return the updated model - var display = Mapper.Map(contentItem.PersistedContent); + var display = ContextMapper.Map(contentItem.PersistedContent, UmbracoContext); //lasty, if it is not valid, add the modelstate to the outgoing object and throw a 403 HandleInvalidModelState(display); @@ -635,8 +644,6 @@ namespace Umbraco.Web.Editors break; } - UpdatePreviewContext(contentItem.PersistedContent.Id); - //If the item is new and the operation was cancelled, we need to return a different // status code so the UI can handle it since it won't be able to redirect since there // is no Id to redirect to! @@ -786,11 +793,8 @@ namespace Umbraco.Web.Editors { var contentService = Services.ContentService; - // content service GetByIds does order the content items based on the order of Ids passed in - var content = contentService.GetByIds(sorted.IdSortOrder); - // Save content with new sort order and update content xml in db accordingly - if (contentService.Sort(content) == false) + if (contentService.Sort(sorted.IdSortOrder) == false) { Logger.Warn("Content sorting failed, this was probably caused by an event being cancelled"); return Request.CreateValidationErrorResponse("Content sorting failed, this was probably caused by an event being cancelled"); @@ -844,6 +848,7 @@ namespace Umbraco.Web.Editors /// /// [EnsureUserPermissionForContent("id", 'U')] + [OutgoingEditorModelEvent] public ContentItemDisplay PostUnPublish(int id) { var foundContent = GetObjectFromRequest(() => Services.ContentService.GetById(id)); @@ -853,7 +858,7 @@ namespace Umbraco.Web.Editors var unpublishResult = Services.ContentService.Unpublish(foundContent, Security.CurrentUser.Id); - var content = Mapper.Map(foundContent); + var content = ContextMapper.Map(foundContent, UmbracoContext); if (unpublishResult.Success == false) { @@ -864,16 +869,6 @@ namespace Umbraco.Web.Editors { content.AddSuccessNotification(Services.TextService.Localize("content/unPublish"), Services.TextService.Localize("speechBubbles/contentUnpublished")); return content; - } - } - - /// - /// Checks if the user is currently in preview mode and if so will update the preview content for this item - /// - /// - private void UpdatePreviewContext(int contentId) - { - _publishedSnapshotService.RefreshPreview(Request.GetPreviewCookieValue(), contentId); } /// diff --git a/src/Umbraco.Web/Editors/CurrentUserController.cs b/src/Umbraco.Web/Editors/CurrentUserController.cs index 0642699b13..7a37a74386 100644 --- a/src/Umbraco.Web/Editors/CurrentUserController.cs +++ b/src/Umbraco.Web/Editors/CurrentUserController.cs @@ -1,4 +1,6 @@ -using System.Net.Http; +using System; +using System.Collections; +using System.Net.Http; using System.Threading.Tasks; using System.Web.Http; using AutoMapper; @@ -8,6 +10,9 @@ using Umbraco.Web.Models; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; +using System.Linq; +using Newtonsoft.Json; +using Umbraco.Core; using Umbraco.Core.Security; using Umbraco.Web.WebApi.Filters; @@ -20,6 +25,49 @@ namespace Umbraco.Web.Editors [PluginController("UmbracoApi")] public class CurrentUserController : UmbracoAuthorizedJsonController { + /// + /// Saves a tour status for the current user + /// + /// + /// + public IEnumerable PostSetUserTour(UserTourStatus status) + { + if (status == null) throw new ArgumentNullException("status"); + + List userTours; + if (Security.CurrentUser.TourData.IsNullOrWhiteSpace()) + { + userTours = new List {status}; + Security.CurrentUser.TourData = JsonConvert.SerializeObject(userTours); + Services.UserService.Save(Security.CurrentUser); + return userTours; + } + + userTours = JsonConvert.DeserializeObject>(Security.CurrentUser.TourData).ToList(); + var found = userTours.FirstOrDefault(x => x.Alias == status.Alias); + if (found != null) + { + //remove it and we'll replace it next + userTours.Remove(found); + } + userTours.Add(status); + Security.CurrentUser.TourData = JsonConvert.SerializeObject(userTours); + Services.UserService.Save(Security.CurrentUser); + return userTours; + } + + /// + /// Returns the user's tours + /// + /// + public IEnumerable GetUserTours() + { + if (Security.CurrentUser.TourData.IsNullOrWhiteSpace()) + return Enumerable.Empty(); + + var userTours = JsonConvert.DeserializeObject>(Security.CurrentUser.TourData); + return userTours; + } /// /// When a user is invited and they click on the invitation link, they will be partially logged in @@ -86,15 +134,12 @@ namespace Umbraco.Web.Editors { var userMgr = this.TryGetOwinContext().Result.GetBackOfficeUserManager(); - //raise the appropriate event + //raise the reset event + //TODO: I don't think this is required anymore since from 7.7 we no longer display the reset password checkbox since that didn't make sense. if (data.Reset.HasValue && data.Reset.Value) { userMgr.RaisePasswordResetEvent(Security.CurrentUser.Id); } - else - { - userMgr.RaisePasswordChangedEvent(Security.CurrentUser.Id); - } //even if we weren't resetting this, it is the correct value (null), otherwise if we were resetting then it will contain the new pword var result = new ModelWithNotifications(passwordChangeResult.Result.ResetPassword); diff --git a/src/Umbraco.Web/Editors/DashboardHelper.cs b/src/Umbraco.Web/Editors/DashboardHelper.cs index 28df8cf526..b40f289d04 100644 --- a/src/Umbraco.Web/Editors/DashboardHelper.cs +++ b/src/Umbraco.Web/Editors/DashboardHelper.cs @@ -69,6 +69,7 @@ namespace Umbraco.Web.Editors var dashboardControl = new DashboardControl(); var controlPath = control.ControlPath.Trim(); + dashboardControl.Caption = control.PanelCaption; dashboardControl.Path = IOHelper.FindFile(controlPath); if (controlPath.ToLowerInvariant().EndsWith(".ascx".ToLowerInvariant())) dashboardControl.ServerSide = true; diff --git a/src/Umbraco.Web/Editors/DataTypeController.cs b/src/Umbraco.Web/Editors/DataTypeController.cs index 1007107b57..148e31d506 100644 --- a/src/Umbraco.Web/Editors/DataTypeController.cs +++ b/src/Umbraco.Web/Editors/DataTypeController.cs @@ -17,6 +17,7 @@ using Constants = Umbraco.Core.Constants; using System.Net.Http; using System.Text; using Umbraco.Web.Composing; +using Umbraco.Core.Configuration; namespace Umbraco.Web.Editors { @@ -343,8 +344,10 @@ namespace Umbraco.Web.Editors public IDictionary> GetGroupedPropertyEditors() { var datatypes = new List(); + var showDeprecatedPropertyEditors = UmbracoConfig.For.UmbracoSettings().Content.ShowDeprecatedPropertyEditors; var propertyEditors = Current.PropertyEditors; + #error .Where(x=>x.IsDeprecated == false || showDeprecatedPropertyEditors); ??? foreach (var propertyEditor in propertyEditors) { var hasPrevalues = propertyEditor.GetConfigurationEditor().Fields.Any(); diff --git a/src/Umbraco.Web/Editors/EditorModelEventArgs.cs b/src/Umbraco.Web/Editors/EditorModelEventArgs.cs new file mode 100644 index 0000000000..153a2d8786 --- /dev/null +++ b/src/Umbraco.Web/Editors/EditorModelEventArgs.cs @@ -0,0 +1,33 @@ +using System; + +namespace Umbraco.Web.Editors +{ + public sealed class EditorModelEventArgs : EditorModelEventArgs + { + public EditorModelEventArgs(EditorModelEventArgs baseArgs) + : base(baseArgs.Model, baseArgs.UmbracoContext) + { + Model = (T)baseArgs.Model; + } + + public EditorModelEventArgs(T model, UmbracoContext umbracoContext) + : base(model, umbracoContext) + { + Model = model; + } + + public new T Model { get; private set; } + } + + public class EditorModelEventArgs : EventArgs + { + public EditorModelEventArgs(object model, UmbracoContext umbracoContext) + { + Model = model; + UmbracoContext = umbracoContext; + } + + public object Model { get; private set; } + public UmbracoContext UmbracoContext { get; private set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Editors/HelpController.cs b/src/Umbraco.Web/Editors/HelpController.cs new file mode 100644 index 0000000000..7cb393aed6 --- /dev/null +++ b/src/Umbraco.Web/Editors/HelpController.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Threading.Tasks; + +namespace Umbraco.Web.Editors +{ + public class HelpController : UmbracoAuthorizedJsonController + { + public async Task> GetContextHelpForPage(string section, string tree, string baseUrl = "https://our.umbraco.org") + { + var url = string.Format(baseUrl + "/Umbraco/Documentation/Lessons/GetContextHelpDocs?sectionAlias={0}&treeAlias={1}", section, tree); + using (var web = new HttpClient()) + { + //fetch dashboard json and parse to JObject + var json = await web.GetStringAsync(url); + var result = JsonConvert.DeserializeObject>(json); + if (result != null) + return result; + + return new List(); + } + } + } + + [DataContract(Name = "HelpPage")] + public class HelpPage + { + [DataMember(Name = "name")] + public string Name { get; set; } + + [DataMember(Name = "description")] + public string Description { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } + + [DataMember(Name = "type")] + public string Type { get; set; } + + } +} diff --git a/src/Umbraco.Web/Editors/LogController.cs b/src/Umbraco.Web/Editors/LogController.cs index e8898ff84d..aa95af201f 100644 --- a/src/Umbraco.Web/Editors/LogController.cs +++ b/src/Umbraco.Web/Editors/LogController.cs @@ -1,8 +1,13 @@ using System; using System.Collections.Generic; +using System.Net; +using System.Net.Http; using AutoMapper; using Umbraco.Web.Models.ContentEditing; using Umbraco.Core.Models; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Umbraco.Core.Persistence.Querying; using Umbraco.Web.Mvc; namespace Umbraco.Web.Editors @@ -13,21 +18,60 @@ namespace Umbraco.Web.Editors [PluginController("UmbracoApi")] public class LogController : UmbracoAuthorizedJsonController { + public PagedResult GetPagedEntityLog(int id, + int pageNumber = 1, + int pageSize = 0, + Direction orderDirection = Direction.Descending, + DateTime? sinceDate = null) + { + long totalRecords; + var dateQuery = sinceDate.HasValue ? Query.Builder.Where(x => x.CreateDate >= sinceDate) : null; + var result = Services.AuditService.GetPagedItemsByEntity(id, pageNumber - 1, pageSize, out totalRecords, orderDirection, customFilter: dateQuery); + var mapped = Mapper.Map>(result); + + var page = new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = MapAvatarsAndNames(mapped) + }; + + return page; + } + + public PagedResult GetPagedCurrentUserLog( + int pageNumber = 1, + int pageSize = 0, + Direction orderDirection = Direction.Descending, + DateTime? sinceDate = null) + { + long totalRecords; + var dateQuery = sinceDate.HasValue ? Query.Builder.Where(x => x.CreateDate >= sinceDate) : null; + var result = Services.AuditService.GetPagedItemsByUser(Security.GetUserId(), pageNumber - 1, pageSize, out totalRecords, orderDirection, customFilter:dateQuery); + var mapped = Mapper.Map>(result); + return new PagedResult(totalRecords, pageNumber + 1, pageSize) + { + Items = MapAvatarsAndNames(mapped) + }; + } + + [Obsolete("Use GetPagedLog instead")] public IEnumerable GetEntityLog(int id) { - return Mapper.Map>( - Services.AuditService.GetLogs(id)); + long totalRecords; + var result = Services.AuditService.GetPagedItemsByEntity(id, 1, int.MaxValue, out totalRecords); + return Mapper.Map>(result); } + //TODO: Move to CurrentUserController? + [Obsolete("Use GetPagedCurrentUserLog instead")] public IEnumerable GetCurrentUserLog(AuditType logType, DateTime? sinceDate) { - if (sinceDate == null) - sinceDate = DateTime.Now.Subtract(new TimeSpan(7, 0, 0, 0, 0)); - - return Mapper.Map>( - Services.AuditService.GetUserLogs(Security.CurrentUser.Id, logType, sinceDate.Value)); + long totalRecords; + var dateQuery = sinceDate.HasValue ? Query.Builder.Where(x => x.CreateDate >= sinceDate) : null; + var result = Services.AuditService.GetPagedItemsByUser(Security.GetUserId(), 1, int.MaxValue, out totalRecords, auditTypeFilter: new[] {logType},customFilter: dateQuery); + return Mapper.Map>(result); } + [Obsolete("Use GetPagedLog instead")] public IEnumerable GetLog(AuditType logType, DateTime? sinceDate) { if (sinceDate == null) @@ -37,5 +81,18 @@ namespace Umbraco.Web.Editors Services.AuditService.GetLogs(logType, sinceDate.Value)); } + private IEnumerable MapAvatarsAndNames(IEnumerable items) + { + var userIds = items.Select(x => x.UserId).ToArray(); + var users = Services.UserService.GetUsersById(userIds) + .ToDictionary(x => x.Id, x => x.GetUserAvatarUrls(ApplicationContext.ApplicationCache.RuntimeCache)); + var userNames = Services.UserService.GetUsersById(userIds).ToDictionary(x => x.Id, x => x.Name); + foreach (var item in items) + { + item.UserAvatars = users[item.UserId]; + item.UserName = userNames[item.UserId]; + } + return items; + } } } diff --git a/src/Umbraco.Web/Editors/MediaController.cs b/src/Umbraco.Web/Editors/MediaController.cs index 5287b06a00..d306ad33af 100644 --- a/src/Umbraco.Web/Editors/MediaController.cs +++ b/src/Umbraco.Web/Editors/MediaController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Globalization; using System.IO; using System.Net; using System.Net.Http; @@ -23,9 +22,7 @@ using Umbraco.Web.Models.Mapping; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; using System.Linq; -using System.Text.RegularExpressions; using System.Web.Http.Controllers; -using Examine; using Umbraco.Web.WebApi.Binders; using Umbraco.Web.WebApi.Filters; using Constants = Umbraco.Core.Constants; @@ -78,7 +75,7 @@ namespace Umbraco.Web.Editors } var emptyContent = Services.MediaService.CreateMedia("", parentId, contentType.Alias, Security.GetUserId().ResultOr(0)); - var mapped = Mapper.Map(emptyContent); + var mapped = ContextMapper.Map(emptyContent, UmbracoContext); //remove this tab if it exists: umbContainerView var containerTab = mapped.Tabs.FirstOrDefault(x => x.Alias == Constants.Conventions.PropertyGroups.ListViewGroupName); @@ -126,7 +123,7 @@ namespace Umbraco.Web.Editors //HandleContentNotFound will throw an exception return null; } - return Mapper.Map(foundContent); + return ContextMapper.Map(foundContent, UmbracoContext); } /// @@ -146,7 +143,7 @@ namespace Umbraco.Web.Editors //HandleContentNotFound will throw an exception return null; } - return Mapper.Map(foundContent); + return ContextMapper.Map(foundContent, UmbracoContext); } /// @@ -175,7 +172,7 @@ namespace Umbraco.Web.Editors public IEnumerable GetByIds([FromUri]int[] ids) { var foundMedia = Services.MediaService.GetByIds(ids); - return foundMedia.Select(Mapper.Map); + return foundMedia.Select(media => ContextMapper.Map(media, UmbracoContext)); } /// @@ -461,6 +458,7 @@ namespace Umbraco.Web.Editors /// [FileUploadCleanupFilter] [MediaPostValidate] + [OutgoingEditorModelEvent] public MediaItemDisplay PostSave( [ModelBinder(typeof(MediaItemBinder))] MediaItemSave contentItem) @@ -487,7 +485,7 @@ namespace Umbraco.Web.Editors { //ok, so the absolute mandatory data is invalid and it's new, we cannot actually continue! // add the modelstate to the outgoing object and throw validation response - var forDisplay = Mapper.Map(contentItem.PersistedContent); + var forDisplay = ContextMapper.Map(contentItem.PersistedContent, UmbracoContext); forDisplay.Errors = ModelState.ToErrorDictionary(); throw new HttpResponseException(Request.CreateValidationErrorResponse(forDisplay)); } @@ -497,7 +495,7 @@ namespace Umbraco.Web.Editors var saveStatus = Services.MediaService.WithResult().Save(contentItem.PersistedContent, (int)Security.CurrentUser.Id); //return the updated model - var display = Mapper.Map(contentItem.PersistedContent); + var display = AutoMapperExtensions.MapWithUmbracoContext(contentItem.PersistedContent, UmbracoContext); //lasty, if it is not valid, add the modelstate to the outgoing object and throw a 403 HandleInvalidModelState(display); @@ -596,15 +594,17 @@ namespace Umbraco.Web.Editors throw; } } - - [EnsureUserPermissionForMedia("folder.ParentId")] - public MediaItemDisplay PostAddFolder(EntityBasic folder) + + public MediaItemDisplay PostAddFolder(PostedFolder folder) { + var intParentId = GetParentIdAsInt(folder.ParentId, validatePermissions:true); + var mediaService = Services.MediaService; - var f = mediaService.CreateMedia(folder.Name, folder.ParentId, Constants.Conventions.MediaTypes.Folder); + + var f = mediaService.CreateMedia(folder.Name, intParentId, Constants.Conventions.MediaTypes.Folder); mediaService.Save(f, Security.CurrentUser.Id); - return Mapper.Map(f); + return ContextMapper.Map(f, UmbracoContext); } /// @@ -636,63 +636,12 @@ namespace Umbraco.Web.Editors } //get the string json from the request - int parentId; bool entityFound; GuidUdi parentUdi; string currentFolderId = result.FormData["currentFolder"]; - // test for udi - if (GuidUdi.TryParse(currentFolderId, out parentUdi)) - { - currentFolderId = parentUdi.Guid.ToString(); - } - - if (int.TryParse(currentFolderId, out parentId) == false) - { - // if a guid then try to look up the entity - Guid idGuid; - if (Guid.TryParse(currentFolderId, out idGuid)) - { - var entity = Services.EntityService.Get(idGuid); - if (entity != null) - { - entityFound = true; - parentId = entity.Id; - } - else - { - throw new EntityNotFoundException(currentFolderId, "The passed id doesn't exist"); - } - } - else - { - return Request.CreateValidationErrorResponse("The request was not formatted correctly, the currentFolder is not an integer or Guid"); - } - - if (entityFound == false) - { - return Request.CreateValidationErrorResponse("The request was not formatted correctly, the currentFolder is not an integer or Guid"); - } - } - - - //ensure the user has access to this folder by parent id! - if (CheckPermissions( - new Dictionary(), - Security.CurrentUser, - Services.MediaService, - Services.EntityService, - parentId) == false) - { - return Request.CreateResponse( - HttpStatusCode.Forbidden, - new SimpleNotificationModel(new Notification( - Services.TextService.Localize("speechBubbles/operationFailedHeader"), - Services.TextService.Localize("speechBubbles/invalidUserPermissionsText"), - SpeechBubbleIcon.Warning))); - } - + int parentId = GetParentIdAsInt(currentFolderId, validatePermissions: true); + var tempFiles = new PostedFiles(); - var mediaService = Services.MediaService; - - + var mediaService = ApplicationContext.Services.MediaService; + //in case we pass a path with a folder in it, we will create it and upload media to it. if (result.FormData.ContainsKey("path")) { @@ -827,6 +776,69 @@ namespace Umbraco.Web.Editors return Request.CreateResponse(HttpStatusCode.OK, tempFiles); } + /// + /// Given a parent id which could be a GUID, UDI or an INT, this will resolve the INT + /// + /// + /// + /// If true, this will check if the current user has access to the resolved integer parent id + /// and if that check fails an unauthorized exception will occur + /// + /// + private int GetParentIdAsInt(string parentId, bool validatePermissions) + { + int intParentId; + GuidUdi parentUdi; + + // test for udi + if (GuidUdi.TryParse(parentId, out parentUdi)) + { + parentId = parentUdi.Guid.ToString(); + } + + //if it's not an INT then we'll check for GUID + if (int.TryParse(parentId, out intParentId) == false) + { + // if a guid then try to look up the entity + Guid idGuid; + if (Guid.TryParse(parentId, out idGuid)) + { + var entity = Services.EntityService.Get(idGuid); + if (entity != null) + { + intParentId = entity.Id; + } + else + { + throw new EntityNotFoundException(parentId, "The passed id doesn't exist"); + } + } + else + { + throw new HttpResponseException( + Request.CreateValidationErrorResponse("The request was not formatted correctly, the parentId is not an integer, Guid or UDI")); + } + } + + //ensure the user has access to this folder by parent id! + if (validatePermissions && CheckPermissions( + new Dictionary(), + Security.CurrentUser, + Services.MediaService, + Services.EntityService, + intParentId) == false) + { + throw new HttpResponseException(Request.CreateResponse( + HttpStatusCode.Forbidden, + new SimpleNotificationModel(new Notification( + Services.TextService.Localize("speechBubbles/operationFailedHeader"), + Services.TextService.Localize("speechBubbles/invalidUserPermissionsText"), + SpeechBubbleIcon.Warning)))); + } + + return intParentId; + } + /// /// Ensures the item can be moved/copied to the new location /// diff --git a/src/Umbraco.Web/Editors/MemberController.cs b/src/Umbraco.Web/Editors/MemberController.cs index fa76652b16..8f4b285160 100644 --- a/src/Umbraco.Web/Editors/MemberController.cs +++ b/src/Umbraco.Web/Editors/MemberController.cs @@ -3,6 +3,8 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net; using System.Net.Http; +using System.Threading.Tasks; +using System.Web; using System.Web.Http; using System.Web.Http.ModelBinding; using System.Web.Security; @@ -62,16 +64,18 @@ namespace Umbraco.Web.Editors if (MembershipScenario == MembershipScenario.NativeUmbraco) { - long totalRecords; var members = Services.MemberService - .GetAll((pageNumber - 1), pageSize, out totalRecords, orderBy, orderDirection, orderBySystemField, memberTypeAlias, filter).ToArray(); + .GetAll((pageNumber - 1), pageSize, out var totalRecords, orderBy, orderDirection, orderBySystemField, memberTypeAlias, filter).ToArray(); if (totalRecords == 0) { return new PagedResult(0, 0, 0); } - var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize); - pagedResult.Items = members - .Select(Mapper.Map); + + var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = members + .Select(x => ContextMapper.Map(x, UmbracoContext)) + }; return pagedResult; } else @@ -99,10 +103,13 @@ namespace Umbraco.Web.Editors { return new PagedResult(0, 0, 0); } - var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize); - pagedResult.Items = members - .Cast() - .Select(Mapper.Map); + + var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = members + .Cast() + .Select(Mapper.Map) + }; return pagedResult; } @@ -150,7 +157,7 @@ namespace Umbraco.Web.Editors { HandleContentNotFound(key); } - return Mapper.Map(foundMember); + return ContextMapper.Map(foundMember, UmbracoContext); case MembershipScenario.CustomProviderWithUmbracoLink: //TODO: Support editing custom properties for members with a custom membership provider here. @@ -210,7 +217,7 @@ namespace Umbraco.Web.Editors emptyContent = new Member(contentType); emptyContent.AdditionalData["NewPassword"] = Membership.GeneratePassword(provider.MinRequiredPasswordLength, provider.MinRequiredNonAlphanumericCharacters); - return Mapper.Map(emptyContent); + return ContextMapper.Map(emptyContent, UmbracoContext); case MembershipScenario.CustomProviderWithUmbracoLink: //TODO: Support editing custom properties for members with a custom membership provider here. @@ -219,7 +226,7 @@ namespace Umbraco.Web.Editors //we need to return a scaffold of a 'simple' member - basically just what a membership provider can edit emptyContent = MemberService.CreateGenericMembershipProviderMember("", "", "", ""); emptyContent.AdditionalData["NewPassword"] = Membership.GeneratePassword(Membership.MinRequiredPasswordLength, Membership.MinRequiredNonAlphanumericCharacters); - return Mapper.Map(emptyContent); + return ContextMapper.Map(emptyContent, UmbracoContext); } } @@ -228,6 +235,7 @@ namespace Umbraco.Web.Editors /// /// [FileUploadCleanupFilter] + [OutgoingEditorModelEvent] public MemberDisplay PostSave( [ModelBinder(typeof(MemberBinder))] MemberSave contentItem) @@ -258,7 +266,7 @@ namespace Umbraco.Web.Editors //Unlike content/media - if there are errors for a member, we do NOT proceed to save them, we cannot so return the errors if (ModelState.IsValid == false) { - var forDisplay = Mapper.Map(contentItem.PersistedContent); + var forDisplay = ContextMapper.Map(contentItem.PersistedContent, UmbracoContext); forDisplay.Errors = ModelState.ToErrorDictionary(); throw new HttpResponseException(Request.CreateValidationErrorResponse(forDisplay)); } @@ -300,7 +308,7 @@ namespace Umbraco.Web.Editors //If we've had problems creating/updating the user with the provider then return the error if (ModelState.IsValid == false) { - var forDisplay = Mapper.Map(contentItem.PersistedContent); + var forDisplay = ContextMapper.Map(contentItem.PersistedContent, UmbracoContext); forDisplay.Errors = ModelState.ToErrorDictionary(); throw new HttpResponseException(Request.CreateValidationErrorResponse(forDisplay)); } @@ -338,7 +346,7 @@ namespace Umbraco.Web.Editors contentItem.PersistedContent.AdditionalData["GeneratedPassword"] = generatedPassword; //return the updated model - var display = Mapper.Map(contentItem.PersistedContent); + var display = ContextMapper.Map(contentItem.PersistedContent, UmbracoContext); //lasty, if it is not valid, add the modelstate to the outgoing object and throw a 403 HandleInvalidModelState(display); @@ -394,6 +402,33 @@ namespace Umbraco.Web.Editors var shouldReFetchMember = false; var providedUserName = contentItem.PersistedContent.Username; + //if the user doesn't have access to sensitive values, then we need to check if any of the built in member property types + //have been marked as sensitive. If that is the case we cannot change these persisted values no matter what value has been posted. + //There's only 3 special ones we need to deal with that are part of the MemberSave instance + if (Security.CurrentUser.HasAccessToSensitiveData() == false) + { + var sensitiveProperties = contentItem.PersistedContent.ContentType + .PropertyTypes.Where(x => contentItem.PersistedContent.ContentType.IsSensitiveProperty(x.Alias)) + .ToList(); + + foreach (var sensitiveProperty in sensitiveProperties) + { + //if found, change the value of the contentItem model to the persisted value so it remains unchanged + switch (sensitiveProperty.Alias) + { + case Constants.Conventions.Member.Comments: + contentItem.Comments = contentItem.PersistedContent.Comments; + break; + case Constants.Conventions.Member.IsApproved: + contentItem.IsApproved = contentItem.PersistedContent.IsApproved; + break; + case Constants.Conventions.Member.IsLockedOut: + contentItem.IsLockedOut = contentItem.PersistedContent.IsLockedOut; + break; + } + } + } + //Update the membership user if it has changed try { @@ -527,7 +562,7 @@ namespace Umbraco.Web.Editors var builtInAliases = Constants.Conventions.Member.GetStandardPropertyTypeStubs().Select(x => x.Key).ToArray(); foreach (var p in contentItem.PersistedContent.Properties) { - var valueMapped = currProps.SingleOrDefault(x => x.Alias == p.Alias); + var valueMapped = currProps.FirstOrDefault(x => x.Alias == p.Alias); if (builtInAliases.Contains(p.Alias) == false && valueMapped != null) { p.SetValue(valueMapped.GetValue()); @@ -747,5 +782,38 @@ namespace Umbraco.Web.Editors return Request.CreateResponse(HttpStatusCode.OK); } + + /// + /// Exports member data based on their unique Id + /// + /// The unique member identifier + /// + [HttpGet] + public HttpResponseMessage ExportMemberData(Guid key) + { + var currentUser = Security.CurrentUser; + + var httpResponseMessage = Request.CreateResponse(); + if (currentUser.HasAccessToSensitiveData() == false) + { + httpResponseMessage.StatusCode = HttpStatusCode.Forbidden; + return httpResponseMessage; + } + + var member = ((MemberService)Services.MemberService).ExportMember(key); + + var fileName = $"{member.Name}_{member.Email}.txt"; + + httpResponseMessage.Content = new ObjectContent(member, new JsonMediaTypeFormatter { Indent = true }); + httpResponseMessage.Content.Headers.Add("x-filename", fileName); + httpResponseMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + httpResponseMessage.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment"); + httpResponseMessage.Content.Headers.ContentDisposition.FileName = fileName; + httpResponseMessage.StatusCode = HttpStatusCode.OK; + + return httpResponseMessage; + } } + + } diff --git a/src/Umbraco.Web/Editors/MemberTypeController.cs b/src/Umbraco.Web/Editors/MemberTypeController.cs index 88a529d3e5..9ff0e33367 100644 --- a/src/Umbraco.Web/Editors/MemberTypeController.cs +++ b/src/Umbraco.Web/Editors/MemberTypeController.cs @@ -108,9 +108,47 @@ namespace Umbraco.Web.Editors public MemberTypeDisplay PostSave(MemberTypeSave contentTypeSave) { + //get the persisted member type + var ctId = Convert.ToInt32(contentTypeSave.Id); + var ct = ctId > 0 ? Services.MemberTypeService.Get(ctId) : null; + + if (UmbracoContext.Security.CurrentUser.HasAccessToSensitiveData() == false) + { + //We need to validate if any properties on the contentTypeSave have had their IsSensitiveValue changed, + //and if so, we need to check if the current user has access to sensitive values. If not, we have to return an error + var props = contentTypeSave.Groups.SelectMany(x => x.Properties); + if (ct != null) + { + foreach (var prop in props) + { + // Id 0 means the property was just added, no need to look it up + if (prop.Id == 0) + continue; + + var foundOnContentType = ct.PropertyTypes.FirstOrDefault(x => x.Id == prop.Id); + if (foundOnContentType == null) + throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.NotFound, "No property type with id " + prop.Id + " found on the content type")); + if (ct.IsSensitiveProperty(foundOnContentType.Alias) && prop.IsSensitiveData == false) + { + //if these don't match, then we cannot continue, this user is not allowed to change this value + throw new HttpResponseException(HttpStatusCode.Forbidden); + } + } + } + else + { + //if it is new, then we can just verify if any property has sensitive data turned on which is not allowed + if (props.Any(prop => prop.IsSensitiveData)) + { + throw new HttpResponseException(HttpStatusCode.Forbidden); + } + } + } + + var savedCt = PerformPostSave( contentTypeSave: contentTypeSave, - getContentType: i => Services.MemberTypeService.Get(i), + getContentType: i => ct, saveContentType: type => Services.MemberTypeService.Save(type)); var display = Mapper.Map(savedCt); diff --git a/src/Umbraco.Web/Editors/PasswordChanger.cs b/src/Umbraco.Web/Editors/PasswordChanger.cs index 7b99023ec4..1343d7cacd 100644 --- a/src/Umbraco.Web/Editors/PasswordChanger.cs +++ b/src/Umbraco.Web/Editors/PasswordChanger.cs @@ -88,7 +88,7 @@ namespace Umbraco.Web.Editors ? userMgr.GeneratePassword() : passwordModel.NewPassword; - var resetResult = await userMgr.ResetPasswordAsync(savingUser.Id, resetToken, newPass); + var resetResult = await userMgr.ChangePasswordWithResetAsync(savingUser.Id, resetToken, newPass); if (resetResult.Succeeded == false) { @@ -161,6 +161,7 @@ namespace Umbraco.Web.Editors } //Are we resetting the password?? + //TODO: I don't think this is required anymore since from 7.7 we no longer display the reset password checkbox since that didn't make sense. if (passwordModel.Reset.HasValue && passwordModel.Reset.Value) { var canReset = membershipProvider.CanResetPassword(_userService); diff --git a/src/Umbraco.Web/Editors/SectionController.cs b/src/Umbraco.Web/Editors/SectionController.cs index 4ee1efc8ae..76821cfc33 100644 --- a/src/Umbraco.Web/Editors/SectionController.cs +++ b/src/Umbraco.Web/Editors/SectionController.cs @@ -50,7 +50,7 @@ namespace Umbraco.Web.Editors { //get the first tree in the section and get it's root node route path var sectionTrees = appTreeController.GetApplicationTrees(section.Alias, null, null).Result; - section.RoutePath = sectionTrees.IsContainer == false + section.RoutePath = sectionTrees.IsContainer == false || sectionTrees.Children.Count == 0 ? sectionTrees.RoutePath : sectionTrees.Children[0].RoutePath; } diff --git a/src/Umbraco.Web/Editors/TourController.cs b/src/Umbraco.Web/Editors/TourController.cs new file mode 100644 index 0000000000..da16659cfe --- /dev/null +++ b/src/Umbraco.Web/Editors/TourController.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Umbraco.Core.Configuration; +using Umbraco.Core.IO; +using Umbraco.Web.Models; +using Umbraco.Web.Mvc; + +namespace Umbraco.Web.Editors +{ + [PluginController("UmbracoApi")] + public class TourController : UmbracoAuthorizedJsonController + { + public IEnumerable GetTours() + { + var result = new List(); + + if (UmbracoConfig.For.UmbracoSettings().BackOffice.Tours.EnableTours == false) + return result; + + var filters = TourFilterResolver.Current.Filters.ToList(); + + //get all filters that will be applied to all tour aliases + var aliasOnlyFilters = filters.Where(x => x.PluginName == null && x.TourFileName == null).ToList(); + + //don't pass in any filters for core tours that have a plugin name assigned + var nonPluginFilters = filters.Where(x => x.PluginName == null).ToList(); + + //add core tour files + var coreToursPath = Path.Combine(IOHelper.MapPath(SystemDirectories.Config), "BackOfficeTours"); + if (Directory.Exists(coreToursPath)) + { + foreach (var tourFile in Directory.EnumerateFiles(coreToursPath, "*.json")) + { + TryParseTourFile(tourFile, result, nonPluginFilters, aliasOnlyFilters); + } + } + + //collect all tour files in packages + foreach (var plugin in Directory.EnumerateDirectories(IOHelper.MapPath(SystemDirectories.AppPlugins))) + { + var pluginName = Path.GetFileName(plugin.TrimEnd('\\')); + var pluginFilters = filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginName)).ToList(); + + //If there is any filter applied to match the plugin only (no file or tour alias) then ignore the plugin entirely + var isPluginFiltered = pluginFilters.Any(x => x.TourFileName == null && x.TourAlias == null); + if (isPluginFiltered) continue; + + //combine matched package filters with filters not specific to a package + var combinedFilters = nonPluginFilters.Concat(pluginFilters).ToList(); + + foreach (var backofficeDir in Directory.EnumerateDirectories(plugin, "backoffice")) + { + foreach (var tourDir in Directory.EnumerateDirectories(backofficeDir, "tours")) + { + foreach (var tourFile in Directory.EnumerateFiles(tourDir, "*.json")) + { + TryParseTourFile(tourFile, result, combinedFilters, aliasOnlyFilters, pluginName); + } + } + } + } + //Get all allowed sections for the current user + var allowedSections = UmbracoContext.Current.Security.CurrentUser.AllowedSections.ToList(); + + var toursToBeRemoved = new List(); + + //Checking to see if the user has access to the required tour sections, else we remove the tour + foreach (var backOfficeTourFile in result) + { + foreach (var tour in backOfficeTourFile.Tours) + { + foreach (var toursRequiredSection in tour.RequiredSections) + { + if (allowedSections.Contains(toursRequiredSection) == false) + { + toursToBeRemoved.Add(backOfficeTourFile); + break; + } + } + } + } + + return result.Except(toursToBeRemoved).OrderBy(x => x.FileName, StringComparer.InvariantCultureIgnoreCase); + } + + private void TryParseTourFile(string tourFile, + ICollection result, + List filters, + List aliasOnlyFilters, + string pluginName = null) + { + var fileName = Path.GetFileNameWithoutExtension(tourFile); + if (fileName == null) return; + + //get the filters specific to this file + var fileFilters = filters.Where(x => x.TourFileName != null && x.TourFileName.IsMatch(fileName)).ToList(); + + //If there is any filter applied to match the file only (no tour alias) then ignore the file entirely + var isFileFiltered = fileFilters.Any(x => x.TourAlias == null); + if (isFileFiltered) return; + + //now combine all aliases to filter below + var aliasFilters = aliasOnlyFilters.Concat(filters.Where(x => x.TourAlias != null)) + .Select(x => x.TourAlias) + .ToList(); + + try + { + var contents = File.ReadAllText(tourFile); + var tours = JsonConvert.DeserializeObject(contents); + + var tour = new BackOfficeTourFile + { + FileName = Path.GetFileNameWithoutExtension(tourFile), + PluginName = pluginName, + Tours = tours + .Where(x => aliasFilters.Count == 0 || aliasFilters.All(filter => filter.IsMatch(x.Alias)) == false) + .ToArray() + }; + + //don't add if all of the tours are filtered + if (tour.Tours.Any()) + result.Add(tour); + } + catch (IOException e) + { + throw new IOException("Error while trying to read file: " + tourFile, e); + } + catch (JsonReaderException e) + { + throw new JsonReaderException("Error while trying to parse content as tour data: " + tourFile, e); + } + } + } +} diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index 3bb1e60be2..56bb0178bb 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -48,7 +48,7 @@ namespace Umbraco.Web.Editors /// public string[] GetCurrentUserAvatarUrls() { - var urls = UmbracoContext.Security.CurrentUser.GetCurrentUserAvatarUrls(Services.UserService, ApplicationCache.StaticCache); + var urls = UmbracoContext.Security.CurrentUser.GetUserAvatarUrls(ApplicationCache.StaticCache); if (urls == null) throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.BadRequest, "Could not access Gravatar endpoint")); @@ -116,7 +116,7 @@ namespace Umbraco.Web.Editors }); } - return request.CreateResponse(HttpStatusCode.OK, user.GetCurrentUserAvatarUrls(userService, staticCache)); + return request.CreateResponse(HttpStatusCode.OK, user.GetUserAvatarUrls(staticCache)); } [AppendUserModifiedHeader("id")] @@ -149,7 +149,7 @@ namespace Umbraco.Web.Editors Current.FileSystems.MediaFileSystem.DeleteFile(filePath); } - return Request.CreateResponse(HttpStatusCode.OK, found.GetCurrentUserAvatarUrls(Services.UserService, ApplicationCache.StaticCache)); + return Request.CreateResponse(HttpStatusCode.OK, found.GetUserAvatarUrls(ApplicationCache.StaticCache)); } /// @@ -157,6 +157,7 @@ namespace Umbraco.Web.Editors /// /// /// + [OutgoingEditorModelEvent] public UserDisplay GetById(int id) { var user = Services.UserService.GetUserById(id); @@ -384,7 +385,7 @@ namespace Umbraco.Web.Editors //send the email - await SendUserInviteEmailAsync(display, Security.CurrentUser.Name, user, userSave.Message); + await SendUserInviteEmailAsync(display, Security.CurrentUser.Name, Security.CurrentUser.Email, user, userSave.Message); return display; } @@ -421,7 +422,7 @@ namespace Umbraco.Web.Editors return attempt.Result; } - private async Task SendUserInviteEmailAsync(UserBasic userDisplay, string from, IUser to, string message) + private async Task SendUserInviteEmailAsync(UserBasic userDisplay, string from, string fromEmail, IUser to, string message) { var token = await UserManager.GenerateEmailConfirmationTokenAsync((int)userDisplay.Id); @@ -450,7 +451,7 @@ namespace Umbraco.Web.Editors var emailBody = Services.TextService.Localize("user/inviteEmailCopyFormat", //Ensure the culture of the found user is used for the email! UserExtensions.GetUserCulture(to.Language, Services.TextService), - new[] { userDisplay.Name, from, message, inviteUri.ToString() }); + new[] { userDisplay.Name, from, message, inviteUri.ToString(), fromEmail }); await UserManager.EmailService.SendAsync( //send the special UmbracoEmailMessage which configures it's own sender @@ -469,6 +470,7 @@ namespace Umbraco.Web.Editors /// /// /// + [OutgoingEditorModelEvent] public async Task PostSaveUser(UserSave userSave) { if (userSave == null) throw new ArgumentNullException("userSave"); @@ -525,7 +527,7 @@ namespace Umbraco.Web.Editors // if the found user has his email for username, we want to keep this synced when changing the email. // we have already cross-checked above that the email isn't colliding with anything, so we can safely assign it here. - if (found.Username == found.Email && userSave.Username != userSave.Email) + if (UmbracoConfig.For.UmbracoSettings().Security.UsernameIsEmail && found.Username == found.Email && userSave.Username != userSave.Email) { userSave.Username = userSave.Email; } @@ -534,19 +536,11 @@ namespace Umbraco.Web.Editors { var passwordChanger = new PasswordChanger(Logger, Services.UserService, UmbracoContext.HttpContext); + //this will change the password and raise appropriate events var passwordChangeResult = await passwordChanger.ChangePasswordWithIdentityAsync(Security.CurrentUser, found, userSave.ChangePassword, UserManager); if (passwordChangeResult.Success) { - var userMgr = this.TryGetOwinContext().Result.GetBackOfficeUserManager(); - - //raise the event - NOTE that the ChangePassword.Reset value here doesn't mean it's been 'reset', it means - //it's been changed by a back office user - if (userSave.ChangePassword.Reset.HasValue && userSave.ChangePassword.Reset.Value) - { - userMgr.RaisePasswordChangedEvent(intId.Result); - } - - //need to re-get the user + //need to re-get the user found = Services.UserService.GetUserById(intId.Result); } else diff --git a/src/Umbraco.Web/ExamineStartup.cs b/src/Umbraco.Web/ExamineStartup.cs new file mode 100644 index 0000000000..f3a8bc3ef5 --- /dev/null +++ b/src/Umbraco.Web/ExamineStartup.cs @@ -0,0 +1,212 @@ +// fixme - Examine is borked +// this cannot compile because it seems to require some changes that are not in Examine v2 +// this should *not* exist, see ExamineComponent instead, which handles everything about Examine + +//using System; +//using System.Collections.Generic; +//using System.Linq; +//using Examine; +//using Examine.Config; +//using Examine.LuceneEngine; +//using Examine.LuceneEngine.Providers; +//using Examine.Providers; +//using Lucene.Net.Index; +//using Lucene.Net.Store; +//using Umbraco.Core; +//using Umbraco.Core.Logging; +//using UmbracoExamine; + +//namespace Umbraco.Web +//{ +// /// +// /// Used to configure Examine during startup for the web application +// /// +// internal class ExamineStartup +// { +// private readonly List _indexesToRebuild = new List(); +// private readonly ApplicationContext _appCtx; +// private readonly ProfilingLogger _profilingLogger; +// private static bool _isConfigured = false; + +// //this is used if we are not the MainDom, in which case we need to ensure that if indexes need rebuilding that this +// //doesn't occur since that should only occur when we are MainDom +// private bool _disableExamineIndexing = false; + +// public ExamineStartup(ApplicationContext appCtx) +// { +// _appCtx = appCtx; +// _profilingLogger = appCtx.ProfilingLogger; +// } + +// /// +// /// Called during the initialize operation of the boot manager process +// /// +// public void Initialize() +// { +// //We want to manage Examine's appdomain shutdown sequence ourselves so first we'll disable Examine's default behavior +// //and then we'll use MainDom to control Examine's shutdown +// ExamineManager.DisableDefaultHostingEnvironmentRegistration(); + +// //we want to tell examine to use a different fs lock instead of the default NativeFSFileLock which could cause problems if the appdomain +// //terminates and in some rare cases would only allow unlocking of the file if IIS is forcefully terminated. Instead we'll rely on the simplefslock +// //which simply checks the existence of the lock file +// DirectoryTracker.DefaultLockFactory = d => +// { +// var simpleFsLockFactory = new NoPrefixSimpleFsLockFactory(d); +// return simpleFsLockFactory; +// }; + +// //This is basically a hack for this item: http://issues.umbraco.org/issue/U4-5976 +// // when Examine initializes it will try to rebuild if the indexes are empty, however in many cases not all of Examine's +// // event handlers will be assigned during bootup when the rebuilding starts which is a problem. So with the examine 0.1.58.2941 build +// // it has an event we can subscribe to in order to cancel this rebuilding process, but what we'll do is cancel it and postpone the rebuilding until the +// // boot process has completed. It's a hack but it works. +// ExamineManager.Instance.BuildingEmptyIndexOnStartup += OnInstanceOnBuildingEmptyIndexOnStartup; + +// //let's deal with shutting down Examine with MainDom +// var examineShutdownRegistered = _appCtx.MainDom.Register(() => +// { +// using (_profilingLogger.TraceDuration("Examine shutting down")) +// { +// //Due to the way Examine's own IRegisteredObject works, we'll first run it with immediate=false and then true so that +// //it's correct subroutines are executed (otherwise we'd have to run this logic manually ourselves) +// ExamineManager.Instance.Stop(false); +// ExamineManager.Instance.Stop(true); +// } +// }); +// if (examineShutdownRegistered) +// { +// _profilingLogger.Logger.Debug("Examine shutdown registered with MainDom"); +// } +// else +// { +// _profilingLogger.Logger.Debug("Examine shutdown not registered, this appdomain is not the MainDom"); + +// //if we could not register the shutdown examine ourselves, it means we are not maindom! in this case all of examine should be disabled +// //from indexing anything on startup!! +// _disableExamineIndexing = true; +// Suspendable.ExamineEvents.SuspendIndexers(); +// } +// } + +// /// +// /// Called during the Complete operation of the boot manager process +// /// +// public void Complete() +// { +// EnsureUnlockedAndConfigured(); + +// //Ok, now that everything is complete we'll check if we've stored any references to index that need rebuilding and run them +// // (see the initialize method for notes) - we'll ensure we remove the event handler too in case examine manager doesn't actually +// // initialize during startup, in which case we want it to rebuild the indexes itself. +// ExamineManager.Instance.BuildingEmptyIndexOnStartup -= OnInstanceOnBuildingEmptyIndexOnStartup; + +// //don't do anything if we have disabled this +// if (_disableExamineIndexing == false) +// { +// foreach (var indexer in _indexesToRebuild) +// { +// indexer.RebuildIndex(); +// } +// } +// } + +// /// +// /// Called to perform the rebuilding indexes on startup if the indexes don't exist +// /// +// public void RebuildIndexes() +// { +// //don't do anything if we have disabled this +// if (_disableExamineIndexing) return; + +// EnsureUnlockedAndConfigured(); + +// //If the developer has explicitly opted out of rebuilding indexes on startup then we +// // should adhere to that and not do it, this means that if they are load balancing things will be +// // out of sync if they are auto-scaling but there's not much we can do about that. +// if (ExamineSettings.Instance.RebuildOnAppStart == false) return; + +// foreach (var indexer in GetIndexesForColdBoot()) +// { +// indexer.RebuildIndex(); +// } +// } + +// /// +// /// The method used to create indexes on a cold boot +// /// +// /// +// /// A cold boot is when the server determines it will not (or cannot) process instructions in the cache table and +// /// will rebuild it's own caches itself. +// /// +// public IEnumerable GetIndexesForColdBoot() +// { +// // NOTE: This is IMPORTANT! ... we don't want to rebuild any index that is already flagged to be re-indexed +// // on startup based on our _indexesToRebuild variable and how Examine auto-rebuilds when indexes are empty. +// // This callback is used above for the DatabaseServerMessenger startup options. + +// // all indexes +// IEnumerable indexes = ExamineManager.Instance.IndexProviderCollection; + +// // except those that are already flagged +// // and are processed in Complete() +// if (_indexesToRebuild.Any()) +// indexes = indexes.Except(_indexesToRebuild); + +// // return +// foreach (var index in indexes) +// yield return index; +// } + +// /// +// /// Must be called to configure each index and ensure it's unlocked before any indexing occurs +// /// +// /// +// /// Indexing rebuilding can occur on a normal boot if the indexes are empty or on a cold boot by the database server messenger. Before +// /// either of these happens, we need to configure the indexes. +// /// +// private void EnsureUnlockedAndConfigured() +// { +// if (_isConfigured) return; + +// _isConfigured = true; + +// foreach (var luceneIndexer in ExamineManager.Instance.IndexProviderCollection.OfType()) +// { +// //We now need to disable waiting for indexing for Examine so that the appdomain is shutdown immediately and doesn't wait for pending +// //indexing operations. We used to wait for indexing operations to complete but this can cause more problems than that is worth because +// //that could end up halting shutdown for a very long time causing overlapping appdomains and many other problems. +// luceneIndexer.WaitForIndexQueueOnShutdown = false; + +// //we should check if the index is locked ... it shouldn't be! We are using simple fs lock now and we are also ensuring that +// //the indexes are not operational unless MainDom is true so if _disableExamineIndexing is false then we should be in charge +// if (_disableExamineIndexing == false) +// { +// var dir = luceneIndexer.GetLuceneDirectory(); +// if (IndexWriter.IsLocked(dir)) +// { +// _profilingLogger.Logger.Info("Forcing index " + luceneIndexer.IndexSetName + " to be unlocked since it was left in a locked state"); +// IndexWriter.Unlock(dir); +// } +// } +// } +// } + +// private void OnInstanceOnBuildingEmptyIndexOnStartup(object sender, BuildingEmptyIndexOnStartupEventArgs args) +// { +// //store the indexer that needs rebuilding because it's empty for when the boot process +// // is complete and cancel this current event so the rebuild process doesn't start right now. +// args.Cancel = true; +// _indexesToRebuild.Add((BaseIndexProvider)args.Indexer); + +// //check if the index is rebuilding due to an error and log it +// if (args.IsHealthy == false) +// { +// var baseIndex = args.Indexer as BaseIndexProvider; +// var name = baseIndex != null ? baseIndex.Name : "[UKNOWN]"; + +// _profilingLogger.Logger.Error(string.Format("The index {0} is rebuilding due to being unreadable/corrupt", name), args.UnhealthyException); +// } +// } +// } +//} diff --git a/src/Umbraco.Web/HealthCheck/Checks/Config/AbstractConfigCheck.cs b/src/Umbraco.Web/HealthCheck/Checks/Config/AbstractConfigCheck.cs index 42cb575491..01863dac0c 100644 --- a/src/Umbraco.Web/HealthCheck/Checks/Config/AbstractConfigCheck.cs +++ b/src/Umbraco.Web/HealthCheck/Checks/Config/AbstractConfigCheck.cs @@ -128,7 +128,7 @@ namespace Umbraco.Web.HealthCheck.Checks.Config public override IEnumerable GetStatus() { - var successMessage = string.Format(CheckSuccessMessage, FileName, XPath, Values, CurrentValue); + var successMessage = string.Format(CheckSuccessMessage, FileName, XPath, Values); var configValue = _configurationService.GetConfigurationValue(); if (configValue.Success == false) @@ -144,6 +144,9 @@ namespace Umbraco.Web.HealthCheck.Checks.Config CurrentValue = configValue.Result; + // need to update the successMessage with the CurrentValue + successMessage = string.Format(CheckSuccessMessage, FileName, XPath, Values, CurrentValue); + var valueFound = Values.Any(value => string.Equals(CurrentValue, value.Value, StringComparison.InvariantCultureIgnoreCase)); if (ValueComparisonType == ValueComparisonType.ShouldEqual && valueFound || ValueComparisonType == ValueComparisonType.ShouldNotEqual && valueFound == false) { diff --git a/src/Umbraco.Web/HealthCheck/HealthCheckController.cs b/src/Umbraco.Web/HealthCheck/HealthCheckController.cs index d051bcd6a3..14642ac843 100644 --- a/src/Umbraco.Web/HealthCheck/HealthCheckController.cs +++ b/src/Umbraco.Web/HealthCheck/HealthCheckController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Configuration; using System.Linq; using System.Web.Http; @@ -7,12 +8,14 @@ using Umbraco.Core.Logging; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.HealthChecks; using Umbraco.Web.Editors; +using Umbraco.Web.WebApi.Filters; namespace Umbraco.Web.HealthCheck { /// /// The API controller used to display the health check info and execute any actions /// + [UmbracoApplicationAuthorize(Core.Constants.Applications.Developer)] public class HealthCheckController : UmbracoAuthorizedJsonController { private readonly HealthCheckCollection _checks; diff --git a/src/Umbraco.Web/HtmlStringUtilities.cs b/src/Umbraco.Web/HtmlStringUtilities.cs index 17ee58ef93..26ee4c6557 100644 --- a/src/Umbraco.Web/HtmlStringUtilities.cs +++ b/src/Umbraco.Web/HtmlStringUtilities.cs @@ -96,6 +96,8 @@ namespace Umbraco.Web using (var outputms = new MemoryStream()) { + bool lengthReached = false; + using (var outputtw = new StreamWriter(outputms)) { using (var ms = new MemoryStream()) @@ -110,7 +112,6 @@ namespace Umbraco.Web using (TextReader tr = new StreamReader(ms)) { bool isInsideElement = false, - lengthReached = false, insideTagSpaceEncountered = false, isTagClose = false; @@ -258,10 +259,15 @@ namespace Umbraco.Web //Check to see if there is an empty char between the hellip and the output string //if there is, remove it - if (string.IsNullOrWhiteSpace(firstTrim) == false) + if (addElipsis && lengthReached && string.IsNullOrWhiteSpace(firstTrim) == false) { result = firstTrim[firstTrim.Length - hellip.Length - 1] == ' ' ? firstTrim.Remove(firstTrim.Length - hellip.Length - 1, 1) : firstTrim; } + else + { + result = firstTrim; + } + return new HtmlString(result); } } diff --git a/src/Umbraco.Web/IPublishedContentQuery.cs b/src/Umbraco.Web/IPublishedContentQuery.cs index 2938fa36d9..ddaae9922d 100644 --- a/src/Umbraco.Web/IPublishedContentQuery.cs +++ b/src/Umbraco.Web/IPublishedContentQuery.cs @@ -32,20 +32,23 @@ namespace Umbraco.Web IEnumerable MediaAtRoot(); /// - /// Searches content + /// Searches content. /// - /// - /// - /// - /// IEnumerable Search(string term, bool useWildCards = true, string searchProvider = null); /// - /// Searhes content + /// Searches content. + /// + IEnumerable Search(int skip, int take, out int totalRecords, string term, bool useWildCards = true, string searchProvider = null); + + /// + /// Searches content. /// - /// - /// - /// IEnumerable Search(Examine.SearchCriteria.ISearchCriteria criteria, Examine.Providers.BaseSearchProvider searchProvider = null); + + /// + /// Searches content. + /// + IEnumerable Search(int skip, int take, out int totalrecords, Examine.SearchCriteria.ISearchCriteria criteria, Examine.Providers.BaseSearchProvider searchProvider = null); } } diff --git a/src/Umbraco.Web/Install/Controllers/InstallController.cs b/src/Umbraco.Web/Install/Controllers/InstallController.cs index 2820455c70..7cf12b9f4c 100644 --- a/src/Umbraco.Web/Install/Controllers/InstallController.cs +++ b/src/Umbraco.Web/Install/Controllers/InstallController.cs @@ -1,4 +1,5 @@ -using System.Web.Mvc; +using System; +using System.Web.Mvc; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.IO; @@ -40,7 +41,8 @@ namespace Umbraco.Web.Install.Controllers { // Update ClientDependency version var clientDependencyConfig = new ClientDependencyConfiguration(_logger); - var clientDependencyUpdated = clientDependencyConfig.IncreaseVersionNumber(); + var clientDependencyUpdated = clientDependencyConfig.UpdateVersionNumber( + UmbracoVersion.SemanticVersion, DateTime.UtcNow, "yyyyMMdd"); // Delete ClientDependency temp directories to make sure we get fresh caches var clientDependencyTempFilesDeleted = clientDependencyConfig.ClearTempFiles(HttpContext); diff --git a/src/Umbraco.Web/Install/InstallHelper.cs b/src/Umbraco.Web/Install/InstallHelper.cs index a0c86f066c..31f79fc0a3 100644 --- a/src/Umbraco.Web/Install/InstallHelper.cs +++ b/src/Umbraco.Web/Install/InstallHelper.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Web; +using Semver; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.IO; @@ -45,7 +46,7 @@ namespace Umbraco.Web.Install { // fixme - should NOT use current everywhere here - inject! new NewInstallStep(_httpContext, Current.Services.UserService, _databaseBuilder), - new UpgradeStep(_databaseBuilder), + new UpgradeStep(), new FilePermissionsStep(), new MajorVersion7UpgradeReport(_databaseBuilder, Current.RuntimeState, Current.SqlContext, Current.ScopeProvider), new Version73FileCleanup(_httpContext, _logger), diff --git a/src/Umbraco.Web/Install/InstallSteps/UpgradeStep.cs b/src/Umbraco.Web/Install/InstallSteps/UpgradeStep.cs index 5bf527d46d..75b027f69f 100644 --- a/src/Umbraco.Web/Install/InstallSteps/UpgradeStep.cs +++ b/src/Umbraco.Web/Install/InstallSteps/UpgradeStep.cs @@ -1,9 +1,4 @@ -using System; -using Semver; -using Umbraco.Core; -using Umbraco.Core.Configuration; -using Umbraco.Core.Migrations.Install; -using Umbraco.Web.Composing; +using Umbraco.Core.Configuration; using Umbraco.Web.Install.Models; namespace Umbraco.Web.Install.InstallSteps @@ -11,17 +6,9 @@ namespace Umbraco.Web.Install.InstallSteps /// /// This step is purely here to show the button to commence the upgrade /// - [InstallSetupStep(InstallationType.Upgrade, - "Upgrade", "upgrade", 1, "Upgrading Umbraco to the latest and greatest version.")] + [InstallSetupStep(InstallationType.Upgrade, "Upgrade", "upgrade", 1, "Upgrading Umbraco to the latest and greatest version.")] internal class UpgradeStep : InstallSetupStep { - private readonly DatabaseBuilder _databaseBuilder; - - public UpgradeStep(DatabaseBuilder databaseBuilder) - { - _databaseBuilder = databaseBuilder; - } - public override bool RequiresExecution(object model) { return true; @@ -36,62 +23,13 @@ namespace Umbraco.Web.Install.InstallSteps { get { - var currentVersion = CurrentVersion().GetVersion(3).ToString(); + // fixme where is the "detected current version"? + var currentVersion = UmbracoVersion.Local.ToString(); var newVersion = UmbracoVersion.Current.ToString(); - var reportUrl = string.Format("https://our.umbraco.org/contribute/releases/compare?from={0}&to={1}¬es=1", currentVersion, newVersion); - - return new - { - currentVersion = currentVersion, - newVersion = newVersion, - reportUrl = reportUrl - }; + var reportUrl = $"https://our.umbraco.org/contribute/releases/compare?from={currentVersion}&to={newVersion}¬es=1"; + return new { currentVersion, newVersion, reportUrl }; } } - - /// - /// Gets the Current Version of the Umbraco Site before an upgrade - /// by using the last/most recent Umbraco Migration that has been run - /// - /// A SemVersion of the latest Umbraco DB Migration run - private SemVersion CurrentVersion() - { - // start with a default version of 0.0.0 - var version = new SemVersion(0); - - //If we have a db context available, if we don't then we are not installed anyways - // fixme DB will not tell the version anymore - only the upgrade state - //if (_databaseBuilder.IsDatabaseConfigured && _databaseBuilder.CanConnect) - // version = _databaseBuilder.ValidateDatabaseSchema().DetermineInstalledVersionByMigrations(Current.Services.MigrationEntryService); - - if (version != new SemVersion(0)) - return version; - - // If we aren't able to get a result from the umbracoMigrations table then use the version in web.config, if it's available - if (string.IsNullOrWhiteSpace(GlobalSettings.ConfigurationStatus)) - return version; - - var configuredVersion = GlobalSettings.ConfigurationStatus; - - string currentComment = null; - - var current = configuredVersion.Split('-'); - if (current.Length > 1) - currentComment = current[1]; - - Version currentVersion; - if (Version.TryParse(current[0], out currentVersion)) - { - version = new SemVersion( - currentVersion.Major, - currentVersion.Minor, - currentVersion.Build, - string.IsNullOrWhiteSpace(currentComment) ? null : currentComment, - currentVersion.Revision > 0 ? currentVersion.Revision.ToString() : null); - } - - return version; - } } } diff --git a/src/Umbraco.Web/Models/BackOfficeTour.cs b/src/Umbraco.Web/Models/BackOfficeTour.cs new file mode 100644 index 0000000000..78a4cd1897 --- /dev/null +++ b/src/Umbraco.Web/Models/BackOfficeTour.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models +{ + /// + /// A model representing a tour. + /// + [DataContract(Name = "tour", Namespace = "")] + public class BackOfficeTour + { + [DataMember(Name = "name")] + public string Name { get; set; } + [DataMember(Name = "alias")] + public string Alias { get; set; } + [DataMember(Name = "group")] + public string Group { get; set; } + [DataMember(Name = "groupOrder")] + public int GroupOrder { get; set; } + [DataMember(Name = "allowDisable")] + public bool AllowDisable { get; set; } + [DataMember(Name = "requiredSections")] + public List RequiredSections { get; set; } + [DataMember(Name = "steps")] + public BackOfficeTourStep[] Steps { get; set; } + } +} diff --git a/src/Umbraco.Web/Models/BackOfficeTourFile.cs b/src/Umbraco.Web/Models/BackOfficeTourFile.cs new file mode 100644 index 0000000000..7291a89ff4 --- /dev/null +++ b/src/Umbraco.Web/Models/BackOfficeTourFile.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models +{ + /// + /// A model representing the file used to load a tour. + /// + [DataContract(Name = "tourFile", Namespace = "")] + public class BackOfficeTourFile + { + /// + /// The file name for the tour + /// + [DataMember(Name = "fileName")] + public string FileName { get; set; } + + /// + /// The plugin folder that the tour comes from + /// + /// + /// If this is null it means it's a Core tour + /// + [DataMember(Name = "pluginName")] + public string PluginName { get; set; } + + [DataMember(Name = "tours")] + public IEnumerable Tours { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/BackOfficeTourFilter.cs b/src/Umbraco.Web/Models/BackOfficeTourFilter.cs new file mode 100644 index 0000000000..994cdb6d29 --- /dev/null +++ b/src/Umbraco.Web/Models/BackOfficeTourFilter.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; + +namespace Umbraco.Web.Models +{ + public class BackOfficeTourFilter + { + public Regex PluginName { get; private set; } + public Regex TourFileName { get; private set; } + public Regex TourAlias { get; private set; } + + /// + /// Create a filter to filter out a whole plugin's tours + /// + /// + /// + public static BackOfficeTourFilter FilterPlugin(Regex pluginName) + { + return new BackOfficeTourFilter(pluginName, null, null); + } + + /// + /// Create a filter to filter out a whole tour file + /// + /// + /// + public static BackOfficeTourFilter FilterFile(Regex tourFileName) + { + return new BackOfficeTourFilter(null, tourFileName, null); + } + + /// + /// Create a filter to filter out a tour alias, this will filter out the same alias found in all files + /// + /// + /// + public static BackOfficeTourFilter FilterAlias(Regex tourAlias) + { + return new BackOfficeTourFilter(null, null, tourAlias); + } + + /// + /// Constructor to create a tour filter + /// + /// Value to filter out tours by a plugin, can be null + /// Value to filter out a tour file, can be null + /// Value to filter out a tour alias, can be null + /// + /// Depending on what is null will depend on how the filter is applied. + /// If pluginName is not NULL and it's matched then we check if tourFileName is not NULL and it's matched then we check tour alias is not NULL and then match it, + /// if any steps is NULL then the filters upstream are applied. + /// Example, pluginName = "hello", tourFileName="stuff", tourAlias=NULL = we will filter out the tour file "stuff" from the plugin "hello" but not from other plugins if the same file name exists. + /// Example, tourAlias="test.*" = we will filter out all tour aliases that start with the word "test" regardless of the plugin or file name + /// + public BackOfficeTourFilter(Regex pluginName, Regex tourFileName, Regex tourAlias) + { + PluginName = pluginName; + TourFileName = tourFileName; + TourAlias = tourAlias; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/BackOfficeTourStep.cs b/src/Umbraco.Web/Models/BackOfficeTourStep.cs new file mode 100644 index 0000000000..a64bf15b7f --- /dev/null +++ b/src/Umbraco.Web/Models/BackOfficeTourStep.cs @@ -0,0 +1,33 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Linq; + +namespace Umbraco.Web.Models +{ + /// + /// A model representing a step in a tour. + /// + [DataContract(Name = "step", Namespace = "")] + public class BackOfficeTourStep + { + [DataMember(Name = "title")] + public string Title { get; set; } + [DataMember(Name = "content")] + public string Content { get; set; } + [DataMember(Name = "type")] + public string Type { get; set; } + [DataMember(Name = "element")] + public string Element { get; set; } + [DataMember(Name = "elementPreventClick")] + public bool ElementPreventClick { get; set; } + [DataMember(Name = "backdropOpacity")] + public float? BackdropOpacity { get; set; } + [DataMember(Name = "event")] + public string Event { get; set; } + [DataMember(Name = "view")] + public string View { get; set; } + [DataMember(Name = "eventElement")] + public string EventElement { get; set; } + [DataMember(Name = "customProperties")] + public JObject CustomProperties { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs b/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs index 236ddec941..3b945f40d9 100644 --- a/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs +++ b/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs @@ -1,24 +1,33 @@ using System; using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Umbraco.Core.Models; namespace Umbraco.Web.Models.ContentEditing { [DataContract(Name = "auditLog", Namespace = "")] public class AuditLog { - [DataMember(Name = "userId", IsRequired = true)] + [DataMember(Name = "userId")] public int UserId { get; set; } - [DataMember(Name = "nodeId", IsRequired = true)] + [DataMember(Name = "userName")] + public string UserName { get; set; } + + [DataMember(Name = "userAvatars")] + public string[] UserAvatars { get; set; } + + [DataMember(Name = "nodeId")] public int NodeId { get; set; } - [DataMember(Name = "timestamp", IsRequired = true)] + [DataMember(Name = "timestamp")] public DateTime Timestamp { get; set; } - [DataMember(Name = "logType", IsRequired = true)] + [DataMember(Name = "logType")] public string LogType { get; set; } - [DataMember(Name = "comment", IsRequired = true)] + [DataMember(Name = "comment")] public string Comment { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentItemDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/ContentItemDisplay.cs index e5562ba22d..cdf13fee7c 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentItemDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentItemDisplay.cs @@ -5,7 +5,6 @@ using Umbraco.Core.Models; namespace Umbraco.Web.Models.ContentEditing { - /// /// A model representing a content item to be displayed in the back office /// @@ -29,6 +28,12 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "template")] public string TemplateAlias { get; set; } + [DataMember(Name = "allowedTemplates")] + public IDictionary AllowedTemplates { get; set; } + + [DataMember(Name = "documentType")] + public ContentTypeBasic DocumentType { get; set; } + [DataMember(Name = "urls")] public string[] Urls { get; set; } diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs index 58c838382a..199b32da9b 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs @@ -30,6 +30,11 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "editor", IsRequired = false)] public string Editor { get; set; } + /// + /// Flags the property to denote that it can contain sensitive data + /// + [DataMember(Name = "isSensitive", IsRequired = false)] + public bool IsSensitive { get; set; } /// /// Used internally during model mapping diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDisplay.cs index f7268b6c27..8a09c62333 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDisplay.cs @@ -35,5 +35,8 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "validation")] public PropertyTypeValidation Validation { get; set; } + + [DataMember(Name = "readonly")] + public bool Readonly { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/EditorNavigation.cs b/src/Umbraco.Web/Models/ContentEditing/EditorNavigation.cs new file mode 100644 index 0000000000..29922750cf --- /dev/null +++ b/src/Umbraco.Web/Models/ContentEditing/EditorNavigation.cs @@ -0,0 +1,26 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models.ContentEditing +{ + /// + /// A model representing the navigation ("apps") inside an editor in the back office + /// + [DataContract(Name = "user", Namespace = "")] + public class EditorNavigation + { + [DataMember(Name = "name")] + public string Name { get; set; } + + [DataMember(Name = "alias")] + public string Alias { get; set; } + + [DataMember(Name = "icon")] + public string Icon { get; set; } + + [DataMember(Name = "view")] + public string View { get; set; } + + [DataMember(Name = "active")] + public bool Active { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/ContentEditing/EntityBasic.cs b/src/Umbraco.Web/Models/ContentEditing/EntityBasic.cs index 471b81ea58..0c26332fa6 100644 --- a/src/Umbraco.Web/Models/ContentEditing/EntityBasic.cs +++ b/src/Umbraco.Web/Models/ContentEditing/EntityBasic.cs @@ -2,10 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Runtime.Serialization; -using System.Text; -using System.Threading.Tasks; using Newtonsoft.Json; using Umbraco.Core; using Umbraco.Core.Models.Validation; diff --git a/src/Umbraco.Web/Models/ContentEditing/MediaItemDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/MediaItemDisplay.cs index 7d314c0392..069ae25547 100644 --- a/src/Umbraco.Web/Models/ContentEditing/MediaItemDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/MediaItemDisplay.cs @@ -10,6 +10,10 @@ namespace Umbraco.Web.Models.ContentEditing [DataContract(Name = "content", Namespace = "")] public class MediaItemDisplay : ListViewAwareContentItemDisplayBase { + [DataMember(Name = "contentType")] + public ContentTypeBasic ContentType { get; set; } + [DataMember(Name = "mediaLink")] + public string MediaLink { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeBasic.cs b/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeBasic.cs index 6927cb44a0..f3fe7a262c 100644 --- a/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeBasic.cs +++ b/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeBasic.cs @@ -13,5 +13,8 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "memberCanEdit")] public bool MemberCanEditProperty { get; set; } + + [DataMember(Name = "isSensitiveData")] + public bool IsSensitiveData { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeDisplay.cs index 0c045afbdd..e5612f5247 100644 --- a/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/MemberPropertyTypeDisplay.cs @@ -10,5 +10,8 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "memberCanEdit")] public bool MemberCanEditProperty { get; set; } + + [DataMember(Name = "isSensitiveData")] + public bool IsSensitiveData { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/PostedFolder.cs b/src/Umbraco.Web/Models/ContentEditing/PostedFolder.cs new file mode 100644 index 0000000000..35cd908787 --- /dev/null +++ b/src/Umbraco.Web/Models/ContentEditing/PostedFolder.cs @@ -0,0 +1,17 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models.ContentEditing +{ + /// + /// Used to create a folder with the MediaController + /// + [DataContract] + public class PostedFolder + { + [DataMember(Name = "parentId")] + public string ParentId { get; set; } + + [DataMember(Name = "name")] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs b/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs index 6b819325e5..dc240bfb78 100644 --- a/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs +++ b/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs @@ -65,7 +65,5 @@ namespace Umbraco.Web.Models.ContentEditing /// [DataMember(Name = "allowedSections")] public IEnumerable AllowedSections { get; set; } - - } } diff --git a/src/Umbraco.Web/Models/ContentEditing/UserDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/UserDisplay.cs index 017f1ea218..35317d8dd5 100644 --- a/src/Umbraco.Web/Models/ContentEditing/UserDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/UserDisplay.cs @@ -18,8 +18,13 @@ namespace Umbraco.Web.Models.ContentEditing AvailableCultures = new Dictionary(); StartContentIds = new List(); StartMediaIds = new List(); + Navigation = new List(); } + [DataMember(Name = "navigation")] + [ReadOnly(true)] + public IEnumerable Navigation { get; set; } + /// /// Gets the available cultures (i.e. to populate a drop down) /// The key is the culture stored in the database, the value is the Name diff --git a/src/Umbraco.Web/Models/Mapping/ActionButtonsResolver.cs b/src/Umbraco.Web/Models/Mapping/ActionButtonsResolver.cs index 609f4fa32a..59b594757f 100644 --- a/src/Umbraco.Web/Models/Mapping/ActionButtonsResolver.cs +++ b/src/Umbraco.Web/Models/Mapping/ActionButtonsResolver.cs @@ -11,33 +11,34 @@ namespace Umbraco.Web.Models.Mapping /// internal class ActionButtonsResolver { - private readonly Lazy _userService; - - public ActionButtonsResolver(Lazy userService) + public ActionButtonsResolver(IUserService userService, IContentService contentService) { - _userService = userService; + UserService = userService; + ContentService = contentService; } + private IUserService UserService { get; } + private IContentService ContentService { get; } + public IEnumerable Resolve(IContent source) { + //cannot check permissions without a context if (UmbracoContext.Current == null) - { - //cannot check permissions without a context return Enumerable.Empty(); + + string path; + if (source.HasIdentity) + path = source.Path; + else + { + var parent = ContentService.GetById(source.ParentId); + path = parent == null ? "-1" : parent.Path; } - var svc = _userService.Value; - var permissions = svc.GetPermissions( - //TODO: This is certainly not ideal usage here - perhaps the best way to deal with this in the future is - // with the IUmbracoContextAccessor. In the meantime, if used outside of a web app this will throw a null - // refrence exception :( - UmbracoContext.Current.Security.CurrentUser, - // Here we need to do a special check since this could be new content, in which case we need to get the permissions - // from the parent, not the existing one otherwise permissions would be coming from the root since Id is 0. - source.HasIdentity ? source.Id : source.ParentId) - .GetAllPermissions(); - - return permissions; + //TODO: This is certainly not ideal usage here - perhaps the best way to deal with this in the future is + // with the IUmbracoContextAccessor. In the meantime, if used outside of a web app this will throw a null + // refrence exception :( + return UserService.GetPermissionsForPath(UmbracoContext.Current.Security.CurrentUser, path).GetAllPermissions(); } } } diff --git a/src/Umbraco.Web/Models/Mapping/AuditMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/AuditMapperProfile.cs new file mode 100644 index 0000000000..34749c51c6 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/AuditMapperProfile.cs @@ -0,0 +1,20 @@ +using AutoMapper; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Models.Mapping +{ + internal class AuditMapperProfile : Profile + { + public AuditMapperProfile() + { + CreateMap() + .ForMember(log => log.UserAvatars, expression => expression.Ignore()) + .ForMember(log => log.UserName, expression => expression.Ignore()) + .ForMember(log => log.NodeId, expression => expression.MapFrom(item => item.Id)) + .ForMember(log => log.Timestamp, expression => expression.MapFrom(item => item.CreateDate)) + .ForMember(log => log.LogType, expression => expression.MapFrom(item => item.AuditType)); + } + } +} diff --git a/src/Umbraco.Web/Models/Mapping/AutoMapperExtensions.cs b/src/Umbraco.Web/Models/Mapping/AutoMapperExtensions.cs new file mode 100644 index 0000000000..fcbb98a7cb --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/AutoMapperExtensions.cs @@ -0,0 +1,31 @@ +using System; +using AutoMapper; + +namespace Umbraco.Web.Models.Mapping +{ + /// + /// Extends AutoMapper's class to handle Umbraco's context. + /// + internal static class ContextMapper + { + private const string UmbracoContextKey = "ContextMapper.UmbracoContext"; + + public static TDestination Map(TSource obj, UmbracoContext umbracoContext) + => Mapper.Map(obj, opt => opt.Items[UmbracoContextKey] = umbracoContext); + + public static UmbracoContext GetUmbracoContext(this ResolutionContext resolutionContext, bool throwIfMissing = true) + { + if (resolutionContext.Options.Items.TryGetValue(UmbracoContextKey, out var obj) && obj is UmbracoContext umbracoContext) + return umbracoContext; + + // not sure this is a good idea at all + //return Current.UmbracoContext; + + // better fail fast + if (throwIfMissing) + throw new InvalidOperationException("AutoMapper ResolutionContext does not contain an UmbracoContext."); + + return null; + } + } +} diff --git a/src/Umbraco.Web/Models/Mapping/ContentChildOfListViewResolver.cs b/src/Umbraco.Web/Models/Mapping/ContentChildOfListViewResolver.cs new file mode 100644 index 0000000000..a2f250d6f3 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/ContentChildOfListViewResolver.cs @@ -0,0 +1,26 @@ +using AutoMapper; +using Umbraco.Core.Models; +using Umbraco.Core.Services; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Models.Mapping +{ + internal class ContentChildOfListViewResolver : IValueResolver + { + private readonly IContentService _contentService; + private readonly IContentTypeService _contentTypeService; + + public ContentChildOfListViewResolver(IContentService contentService, IContentTypeService contentTypeService) + { + _contentService = contentService; + _contentTypeService = contentTypeService; + } + + public bool Resolve(IContent source, ContentItemDisplay destination, bool destMember, ResolutionContext context) + { + // map the IsChildOfListView (this is actually if it is a descendant of a list view!) + var parent = _contentService.GetParent(source); + return parent != null && (parent.ContentType.IsContainer || _contentTypeService.HasContainerInPath(parent.Path)); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs index dff38938e0..16f14c83d8 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs @@ -1,21 +1,28 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Web; -using System.Web.Mvc; +using System.Linq; using AutoMapper; using Umbraco.Core; using Umbraco.Core.Models; using Umbraco.Core.Services; -using Umbraco.Web.Composing; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Routing; using Umbraco.Web.Trees; -using Umbraco.Web._Legacy.Actions; namespace Umbraco.Web.Models.Mapping { + internal class ContentUrlResolver : IValueResolver + { + public string[] Resolve(IContent source, ContentItemDisplay destination, string[] destMember, ResolutionContext context) + { + var umbracoContext = context.GetUmbracoContext(); + + var urls = umbracoContext == null + ? new[] {"Cannot generate urls without a current Umbraco Context"} + : source.GetContentUrls(umbracoContext).ToArray(); + + return urls; + } + } + /// /// Declares how model mappings for content /// @@ -26,13 +33,17 @@ namespace Umbraco.Web.Models.Mapping // create, capture, cache var contentOwnerResolver = new OwnerResolver(userService); var creatorResolver = new CreatorResolver(userService); - var actionButtonsResolver = new ActionButtonsResolver(new Lazy(() => userService)); - var tabsAndPropertiesResolver = new TabsAndPropertiesResolver(textService); + var actionButtonsResolver = new ActionButtonsResolver(userService, contentService); + var tabsAndPropertiesResolver = new TabsAndPropertiesResolver(textService); + var childOfListViewResolver = new ContentChildOfListViewResolver(contentService, contentTypeService); + var contentTypeBasicResolver = new ContentTypeBasicResolver(); + var contentTreeNodeUrlResolver = new ContentTreeNodeUrlResolver(); + var defaultTemplateResolver = new DefaultTemplateResolver(); + var contentUrlResolver = new ContentUrlResolver(); //FROM IContent TO ContentItemDisplay CreateMap() - .ForMember(dest => dest.Udi, opt => opt.MapFrom(src => - Udi.Create(src.Blueprint ? Constants.UdiEntityType.DocumentBluePrint : Constants.UdiEntityType.Document, src.Key))) + .ForMember(dest => dest.Udi, opt => opt.MapFrom(src => Udi.Create(src.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, src.Key))) .ForMember(dest => dest.Owner, opt => opt.ResolveUsing(src => contentOwnerResolver.Resolve(src))) .ForMember(dest => dest.Updater, opt => opt.ResolveUsing(src => creatorResolver.Resolve(src))) .ForMember(dest => dest.Icon, opt => opt.MapFrom(src => src.ContentType.Icon)) @@ -40,29 +51,35 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.ContentTypeName, opt => opt.MapFrom(src => src.ContentType.Name)) .ForMember(dest => dest.IsContainer, opt => opt.MapFrom(src => src.ContentType.IsContainer)) .ForMember(dest => dest.IsBlueprint, opt => opt.MapFrom(src => src.Blueprint)) - .ForMember(dest => dest.IsChildOfListView, opt => opt.Ignore()) + .ForMember(dest => dest.IsChildOfListView, opt => opt.ResolveUsing(childOfListViewResolver)) .ForMember(dest => dest.Trashed, opt => opt.MapFrom(src => src.Trashed)) .ForMember(dest => dest.PublishDate, opt => opt.MapFrom(src => src.PublishDate)) - .ForMember(dest => dest.TemplateAlias, opt => opt.MapFrom(src => src.Template.Alias)) - .ForMember(dest => dest.Urls, opt => opt.MapFrom(src => - UmbracoContext.Current == null - ? new[] {"Cannot generate urls without a current Umbraco Context"} - : src.GetContentUrls(UmbracoContext.Current))) + .ForMember(dest => dest.TemplateAlias, opt => opt.ResolveUsing(defaultTemplateResolver)) + .ForMember(dest => dest.Urls, opt => opt.ResolveUsing(contentUrlResolver)) .ForMember(dest => dest.Properties, opt => opt.Ignore()) .ForMember(dest => dest.AllowPreview, opt => opt.Ignore()) - .ForMember(dest => dest.TreeNodeUrl, opt => opt.Ignore()) + .ForMember(dest => dest.TreeNodeUrl, opt => opt.ResolveUsing(contentTreeNodeUrlResolver)) .ForMember(dest => dest.Notifications, opt => opt.Ignore()) .ForMember(dest => dest.Errors, opt => opt.Ignore()) .ForMember(dest => dest.Alias, opt => opt.Ignore()) - .ForMember(dest => dest.Tabs, opt => opt.ResolveUsing(src => tabsAndPropertiesResolver.Resolve(src))) + .ForMember(dest => dest.DocumentType, opt => opt.ResolveUsing(contentTypeBasicResolver)) + .ForMember(dest => dest.AllowedTemplates, opt => + opt.MapFrom(content => content.ContentType.AllowedTemplates + .Where(t => t.Alias.IsNullOrWhiteSpace() == false && t.Name.IsNullOrWhiteSpace() == false) + .ToDictionary(t => t.Alias, t => t.Name))) + .ForMember(dest => dest.Tabs, opt => opt.ResolveUsing(tabsAndPropertiesResolver)) .ForMember(dest => dest.AllowedActions, opt => opt.ResolveUsing(src => actionButtonsResolver.Resolve(src))) .ForMember(dest => dest.AdditionalData, opt => opt.Ignore()) - .AfterMap((src, dest) => AfterMap(src, dest, dataTypeService, textService, contentTypeService, contentService)); + .AfterMap((content, display) => + { + if (content.ContentType.IsContainer) + TabsAndPropertiesResolver.AddListView(display, "content", dataTypeService, textService); + }); //FROM IContent TO ContentItemBasic CreateMap>() .ForMember(dest => dest.Udi, opt => opt.MapFrom(src => - Udi.Create(src.Blueprint ? Constants.UdiEntityType.DocumentBluePrint : Constants.UdiEntityType.Document, src.Key))) + Udi.Create(src.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, src.Key))) .ForMember(dest => dest.Owner, opt => opt.ResolveUsing(src => contentOwnerResolver.Resolve(src))) .ForMember(dest => dest.Updater, opt => opt.ResolveUsing(src => creatorResolver.Resolve(src))) .ForMember(dest => dest.Icon, opt => opt.MapFrom(src => src.ContentType.Icon)) @@ -74,141 +91,12 @@ namespace Umbraco.Web.Models.Mapping //FROM IContent TO ContentItemDto CreateMap>() .ForMember(dest => dest.Udi, opt => opt.MapFrom(src => - Udi.Create(src.Blueprint ? Constants.UdiEntityType.DocumentBluePrint : Constants.UdiEntityType.Document, src.Key))) + Udi.Create(src.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, src.Key))) .ForMember(dest => dest.Owner, opt => opt.ResolveUsing(src => contentOwnerResolver.Resolve(src))) .ForMember(dest => dest.Updater, opt => opt.Ignore()) .ForMember(dest => dest.Icon, opt => opt.Ignore()) .ForMember(dest => dest.Alias, opt => opt.Ignore()) .ForMember(dest => dest.AdditionalData, opt => opt.Ignore()); } - - /// - /// Maps the generic tab with custom properties for content - /// - /// - /// - /// - /// - /// - /// - private static void AfterMap(IContent content, ContentItemDisplay display, IDataTypeService dataTypeService, - ILocalizedTextService localizedText, IContentTypeService contentTypeService, IContentService contentService) - { - // map the IsChildOfListView (this is actually if it is a descendant of a list view!) - var parent = content.Parent(contentService); - display.IsChildOfListView = parent != null && (parent.ContentType.IsContainer || contentTypeService.HasContainerInPath(parent.Path)); - - //map the tree node url - if (HttpContext.Current != null) - { - var urlHelper = new UrlHelper(HttpContext.Current.Request.RequestContext); - var url = urlHelper.GetUmbracoApiService(controller => controller.GetTreeNode(display.Id.ToString(), null)); - display.TreeNodeUrl = url; - } - - //fill in the template config to be passed to the template drop down. - var templateItemConfig = new Dictionary {{"", localizedText.Localize("general/choose") } }; - foreach (var t in content.ContentType.AllowedTemplates - .Where(t => t.Alias.IsNullOrWhiteSpace() == false && t.Name.IsNullOrWhiteSpace() == false)) - { - templateItemConfig.Add(t.Alias, t.Name); - } - - if (content.ContentType.IsContainer) - { - TabsAndPropertiesResolver.AddListView(display, "content", dataTypeService, localizedText); - } - - var properties = new List - { - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}doctype", - Label = localizedText.Localize("content/documentType"), - Value = localizedText.UmbracoDictionaryTranslate(display.ContentTypeName), - View = Current.PropertyEditors[Constants.PropertyEditors.Aliases.NoEdit].GetValueEditor().View - }, - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}releasedate", - Label = localizedText.Localize("content/releaseDate"), - Value = display.ReleaseDate?.ToIsoString(), - //Not editible for people without publish permission (U4-287) - View = display.AllowedActions.Contains(ActionPublish.Instance.Letter.ToString(CultureInfo.InvariantCulture)) ? "datepicker" : Current.PropertyEditors[Constants.PropertyEditors.Aliases.NoEdit].GetValueEditor().View, - Config = new Dictionary - { - {"offsetTime", "1"} - } - //TODO: Fix up hard coded datepicker - }, - new ContentPropertyDisplay - { - Alias = string.Format("{0}expiredate", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("content/unpublishDate"), - Value = display.ExpireDate.HasValue ? display.ExpireDate.Value.ToIsoString() : null, - //Not editible for people without publish permission (U4-287) - View = display.AllowedActions.Contains(ActionPublish.Instance.Letter.ToString(CultureInfo.InvariantCulture)) ? "datepicker" : Current.PropertyEditors[Constants.PropertyEditors.Aliases.NoEdit].GetValueEditor().View, - Config = new Dictionary - { - {"offsetTime", "1"} - } - //TODO: Fix up hard coded datepicker - }, - new ContentPropertyDisplay - { - Alias = string.Format("{0}template", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("template/template"), - Value = display.TemplateAlias, - View = "dropdown", //TODO: Hard coding until we make a real dropdown property editor to lookup - Config = new Dictionary - { - {"items", templateItemConfig} - } - } - }; - - - TabsAndPropertiesResolver.MapGenericProperties(content, display, localizedText, properties.ToArray(), - genericProperties => - { - //TODO: This would be much nicer with the IUmbracoContextAccessor so we don't use singletons - //If this is a web request and there's a user signed in and the - // user has access to the settings section, we will - if (HttpContext.Current != null && UmbracoContext.Current != null && UmbracoContext.Current.Security.CurrentUser != null - && UmbracoContext.Current.Security.CurrentUser.AllowedSections.Any(x => x.Equals(Constants.Applications.Settings))) - { - var currentDocumentType = contentTypeService.Get(display.ContentTypeAlias); - var currentDocumentTypeName = currentDocumentType == null ? string.Empty : localizedText.UmbracoDictionaryTranslate(currentDocumentType.Name); - - var currentDocumentTypeId = currentDocumentType == null ? string.Empty : currentDocumentType.Id.ToString(CultureInfo.InvariantCulture); - //TODO: Hard coding this is not good - var docTypeLink = string.Format("#/settings/documenttypes/edit/{0}", currentDocumentTypeId); - - //Replace the doc type property - var docTypeProperty = genericProperties.First(x => x.Alias == string.Format("{0}doctype", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - docTypeProperty.Value = new List - { - new - { - linkText = currentDocumentTypeName, - url = docTypeLink, - target = "_self", - icon = "icon-item-arrangement" - } - }; - //TODO: Hard coding this because the templatepicker doesn't necessarily need to be a resolvable (real) property editor - docTypeProperty.View = "urllist"; - } - - // inject 'Link to document' as the first generic property - genericProperties.Insert(0, new ContentPropertyDisplay - { - Alias = string.Format("{0}urls", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("content/urls"), - Value = string.Join(",", display.Urls), - View = "urllist" //TODO: Hard coding this because the templatepicker doesn't necessarily need to be a resolvable (real) property editor - }); - }); - } } } diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicConverter.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicConverter.cs index b4f0436660..aff472d306 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicConverter.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicConverter.cs @@ -15,13 +15,11 @@ namespace Umbraco.Web.Models.Mapping internal class ContentPropertyBasicConverter : ITypeConverter where TDestination : ContentPropertyBasic, new() { - private readonly Lazy _dataTypeService; + protected IDataTypeService DataTypeService { get; } - protected IDataTypeService DataTypeService => _dataTypeService.Value; - - public ContentPropertyBasicConverter(Lazy dataTypeService) + public ContentPropertyBasicConverter(IDataTypeService dataTypeService) { - _dataTypeService = dataTypeService; + DataTypeService = dataTypeService; } /// diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayConverter.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayConverter.cs index 99540fc6ea..c3a5750078 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayConverter.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayConverter.cs @@ -15,10 +15,13 @@ namespace Umbraco.Web.Models.Mapping /// internal class ContentPropertyDisplayConverter : ContentPropertyBasicConverter { - public ContentPropertyDisplayConverter(Lazy dataTypeService) - : base(dataTypeService) - { } + private readonly ILocalizedTextService _textService; + public ContentPropertyDisplayConverter(IDataTypeService dataTypeService, ILocalizedTextService textService) + : base(dataTypeService) + { + _textService = textService; + } public override ContentPropertyDisplay Convert(Property originalProp, ContentPropertyDisplay dest, ResolutionContext context) { var display = base.Convert(originalProp, dest, context); @@ -58,6 +61,10 @@ namespace Umbraco.Web.Models.Mapping display.View = valEditor.View; } + //Translate + display.Label = _textService.UmbracoDictionaryTranslate(display.Label); + display.Description = _textService.UmbracoDictionaryTranslate(display.Description); + return display; } } diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoConverter.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoConverter.cs index c1cad75674..66f42fc2ad 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoConverter.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoConverter.cs @@ -11,7 +11,7 @@ namespace Umbraco.Web.Models.Mapping /// internal class ContentPropertyDtoConverter : ContentPropertyBasicConverter { - public ContentPropertyDtoConverter(Lazy dataTypeService) + public ContentPropertyDtoConverter(IDataTypeService dataTypeService) : base(dataTypeService) { } diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyMapperProfile.cs index 26cb6c40a5..7764613a0e 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyMapperProfile.cs @@ -1,5 +1,4 @@ -using System; -using AutoMapper; +using AutoMapper; using Umbraco.Core.Models; using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; @@ -12,13 +11,11 @@ namespace Umbraco.Web.Models.Mapping /// internal class ContentPropertyMapperProfile : Profile { - private readonly IDataTypeService _dataTypeService; - - public ContentPropertyMapperProfile(IDataTypeService dataTypeService) + public ContentPropertyMapperProfile(IDataTypeService dataTypeService, ILocalizedTextService textService) { - _dataTypeService = dataTypeService; - - var lazyDataTypeService = new Lazy(() => _dataTypeService); + var contentPropertyBasicConverter = new ContentPropertyBasicConverter(dataTypeService); + var contentPropertyDtoConverter = new ContentPropertyDtoConverter(dataTypeService); + var contentPropertyDisplayConverter = new ContentPropertyDisplayConverter(dataTypeService, textService); //FROM Property TO ContentPropertyBasic CreateMap>() @@ -28,16 +25,13 @@ namespace Umbraco.Web.Models.Mapping .ForMember(tab => tab.Alias, expression => expression.Ignore()); //FROM Property TO ContentPropertyBasic - CreateMap() - .ConvertUsing(new ContentPropertyBasicConverter(lazyDataTypeService)); + CreateMap().ConvertUsing(contentPropertyBasicConverter); //FROM Property TO ContentPropertyDto - CreateMap() - .ConvertUsing(new ContentPropertyDtoConverter(lazyDataTypeService)); + CreateMap().ConvertUsing(contentPropertyDtoConverter); //FROM Property TO ContentPropertyDisplay - CreateMap() - .ConvertUsing(new ContentPropertyDisplayConverter(lazyDataTypeService)); + CreateMap().ConvertUsing(contentPropertyDisplayConverter); } } } diff --git a/src/Umbraco.Web/Models/Mapping/ContentTreeNodeUrlResolver.cs b/src/Umbraco.Web/Models/Mapping/ContentTreeNodeUrlResolver.cs new file mode 100644 index 0000000000..48400f51f6 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/ContentTreeNodeUrlResolver.cs @@ -0,0 +1,24 @@ +using System.Web.Mvc; +using AutoMapper; +using Umbraco.Core.Models; +using Umbraco.Web.Trees; + +namespace Umbraco.Web.Models.Mapping +{ + /// + /// Gets the tree node url for the content or media + /// + internal class ContentTreeNodeUrlResolver : IValueResolver + where TSource : IContentBase + where TController : ContentTreeControllerBase + { + public string Resolve(TSource source, object destination, string destMember, ResolutionContext context) + { + var umbracoContext = context.GetUmbracoContext(throwIfMissing: false); + if (umbracoContext == null) return null; + + var urlHelper = new UrlHelper(umbracoContext.HttpContext.Request.RequestContext); + return urlHelper.GetUmbracoApiService(controller => controller.GetTreeNode(source.Key.ToString("N"), null)); + } + } +} diff --git a/src/Umbraco.Web/Models/Mapping/ContentTypeBasicResolver.cs b/src/Umbraco.Web/Models/Mapping/ContentTypeBasicResolver.cs new file mode 100644 index 0000000000..db32908352 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/ContentTypeBasicResolver.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; +using System.Web; +using AutoMapper; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Models.Mapping +{ + /// + /// Resolves a from the item and checks if the current user + /// has access to see this data + /// + internal class ContentTypeBasicResolver : IValueResolver + where TSource : IContentBase + { + public ContentTypeBasic Resolve(TSource source, TDestination destination, ContentTypeBasic destMember, ResolutionContext context) + { + //TODO: We can resolve the UmbracoContext from the IValueResolver options! + // OMG + if (HttpContext.Current != null && UmbracoContext.Current != null && UmbracoContext.Current.Security.CurrentUser != null + && UmbracoContext.Current.Security.CurrentUser.AllowedSections.Any(x => x.Equals(Constants.Applications.Settings))) + { + ContentTypeBasic contentTypeBasic; + if (source is IContent content) + contentTypeBasic = Mapper.Map(content.ContentType); + else if (source is IMedia media) + contentTypeBasic = Mapper.Map(media.ContentType); + else + throw new NotSupportedException($"Expected TSource to be IContent or IMedia, got {typeof(TSource).Name}."); + + return contentTypeBasic; + } + //no access + return null; + } + } +} diff --git a/src/Umbraco.Web/Models/Mapping/ContentTypeMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/ContentTypeMapperProfile.cs index fcfdddfc7b..c9e9554b93 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentTypeMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentTypeMapperProfile.cs @@ -14,39 +14,8 @@ namespace Umbraco.Web.Models.Mapping /// internal class ContentTypeMapperProfile : Profile { - private readonly PropertyEditorCollection _propertyEditors; - private readonly IDataTypeService _dataTypeService; - private readonly IFileService _fileService; - private readonly IContentTypeService _contentTypeService; - private readonly IMediaTypeService _mediaTypeService; - public ContentTypeMapperProfile(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IFileService fileService, IContentTypeService contentTypeService, IMediaTypeService mediaTypeService) { - _propertyEditors = propertyEditors; - _dataTypeService = dataTypeService; - _fileService = fileService; - _contentTypeService = contentTypeService; - _mediaTypeService = mediaTypeService; - - // v7 creates this map twice which makes no sense, and AutoMapper 6 detects it - // assuming the second map took over, and removing the first one for now - /* - CreateMap() - .ConstructUsing(basic => new PropertyType(_dataTypeService.GetDataTypeDefinitionById(basic.DataTypeId))) - .ForMember(type => type.ValidationRegExp, opt => opt.ResolveUsing(basic => basic.Validation.Pattern)) - .ForMember(type => type.Mandatory, opt => opt.ResolveUsing(basic => basic.Validation.Mandatory)) - .ForMember(type => type.Name, opt => opt.ResolveUsing(basic => basic.Label)) - .ForMember(type => type.DataTypeDefinitionId, opt => opt.ResolveUsing(basic => basic.DataTypeId)) - .ForMember(type => type.DataTypeId, opt => opt.Ignore()) - .ForMember(type => type.PropertyEditorAlias, opt => opt.Ignore()) - .ForMember(type => type.HelpText, opt => opt.Ignore()) - .ForMember(type => type.Key, opt => opt.Ignore()) - .ForMember(type => type.CreateDate, opt => opt.Ignore()) - .ForMember(type => type.UpdateDate, opt => opt.Ignore()) - .ForMember(type => type.DeletedDate, opt => opt.Ignore()) - .ForMember(type => type.HasIdentity, opt => opt.Ignore()); - */ - CreateMap() //do the base mapping .MapBaseContentTypeSaveToEntity() @@ -57,13 +26,15 @@ namespace Umbraco.Web.Models.Mapping { dest.AllowedTemplates = source.AllowedTemplates .Where(x => x != null) - .Select(s => _fileService.GetTemplate(s)) + .Select(s => fileService.GetTemplate(s)) .ToArray(); if (source.DefaultTemplate != null) - dest.SetDefaultTemplate(_fileService.GetTemplate(source.DefaultTemplate)); + dest.SetDefaultTemplate(fileService.GetTemplate(source.DefaultTemplate)); + else + dest.SetDefaultTemplate(null); - ContentTypeProfileExtensions.AfterMapContentTypeSaveToEntity(source, dest, _contentTypeService); + ContentTypeProfileExtensions.AfterMapContentTypeSaveToEntity(source, dest, contentTypeService); }); CreateMap() @@ -72,7 +43,7 @@ namespace Umbraco.Web.Models.Mapping .ConstructUsing((source) => new MediaType(source.ParentId)) .AfterMap((source, dest) => { - ContentTypeProfileExtensions.AfterMapMediaTypeSaveToEntity(source, dest, _mediaTypeService); + ContentTypeProfileExtensions.AfterMapMediaTypeSaveToEntity(source, dest, mediaTypeService); }); CreateMap() @@ -81,9 +52,9 @@ namespace Umbraco.Web.Models.Mapping .ConstructUsing(source => new MemberType(source.ParentId)) .AfterMap((source, dest) => { - ContentTypeProfileExtensions.AfterMapContentTypeSaveToEntity(source, dest, _contentTypeService); + ContentTypeProfileExtensions.AfterMapContentTypeSaveToEntity(source, dest, contentTypeService); - //map the MemberCanEditProperty,MemberCanViewProperty + //map the MemberCanEditProperty,MemberCanViewProperty,IsSensitiveData foreach (var propertyType in source.Groups.SelectMany(x => x.Properties)) { var localCopy = propertyType; @@ -92,6 +63,7 @@ namespace Umbraco.Web.Models.Mapping { dest.SetMemberCanEditProperty(localCopy.Alias, localCopy.MemberCanEditProperty); dest.SetMemberCanViewProperty(localCopy.Alias, localCopy.MemberCanViewProperty); + dest.SetIsSensitiveProperty(localCopy.Alias, localCopy.IsSensitiveData); } } }); @@ -100,10 +72,10 @@ namespace Umbraco.Web.Models.Mapping CreateMap() //map base logic - .MapBaseContentTypeEntityToDisplay(_propertyEditors, _dataTypeService, _contentTypeService) + .MapBaseContentTypeEntityToDisplay(propertyEditors, dataTypeService, contentTypeService) .AfterMap((memberType, display) => { - //map the MemberCanEditProperty,MemberCanViewProperty + //map the MemberCanEditProperty,MemberCanViewProperty,IsSensitiveData foreach (var propertyType in memberType.PropertyTypes) { var localCopy = propertyType; @@ -112,13 +84,14 @@ namespace Umbraco.Web.Models.Mapping { displayProp.MemberCanEditProperty = memberType.MemberCanEditProperty(localCopy.Alias); displayProp.MemberCanViewProperty = memberType.MemberCanViewProperty(localCopy.Alias); + displayProp.IsSensitiveData = memberType.IsSensitiveProperty(localCopy.Alias); } } }); CreateMap() //map base logic - .MapBaseContentTypeEntityToDisplay(_propertyEditors, _dataTypeService, _contentTypeService) + .MapBaseContentTypeEntityToDisplay(propertyEditors, dataTypeService, contentTypeService) .AfterMap((source, dest) => { //default listview @@ -127,14 +100,14 @@ namespace Umbraco.Web.Models.Mapping if (string.IsNullOrEmpty(source.Name) == false) { var name = Constants.Conventions.DataTypes.ListViewPrefix + source.Name; - if (_dataTypeService.GetDataType(name) != null) + if (dataTypeService.GetDataType(name) != null) dest.ListViewEditorName = name; } }); CreateMap() //map base logic - .MapBaseContentTypeEntityToDisplay(_propertyEditors, _dataTypeService, _contentTypeService) + .MapBaseContentTypeEntityToDisplay(propertyEditors, dataTypeService, contentTypeService) .ForMember(dto => dto.AllowedTemplates, opt => opt.Ignore()) .ForMember(dto => dto.DefaultTemplate, opt => opt.Ignore()) .ForMember(display => display.Notifications, opt => opt.Ignore()) @@ -152,7 +125,7 @@ namespace Umbraco.Web.Models.Mapping if (string.IsNullOrEmpty(source.Alias) == false) { var name = Constants.Conventions.DataTypes.ListViewPrefix + source.Alias; - if (_dataTypeService.GetDataType(name) != null) + if (dataTypeService.GetDataType(name) != null) dest.ListViewEditorName = name; } @@ -175,7 +148,7 @@ namespace Umbraco.Web.Models.Mapping .ConstructUsing(propertyTypeBasic => { - var dataType = _dataTypeService.GetDataType(propertyTypeBasic.DataTypeId); + var dataType = dataTypeService.GetDataType(propertyTypeBasic.DataTypeId); if (dataType == null) throw new NullReferenceException("No data type found with id " + propertyTypeBasic.DataTypeId); return new PropertyType(dataType, propertyTypeBasic.Alias); }) @@ -226,7 +199,7 @@ namespace Umbraco.Web.Models.Mapping //if the dest is set and it's the same as the source, then don't change if (destAllowedTemplateAliases.SequenceEqual(source.AllowedTemplates) == false) { - var templates = _fileService.GetTemplates(source.AllowedTemplates.ToArray()); + var templates = fileService.GetTemplates(source.AllowedTemplates.ToArray()); dest.AllowedTemplates = source.AllowedTemplates .Select(x => Mapper.Map(templates.SingleOrDefault(t => t.Alias == x))) .WhereNotNull() @@ -238,7 +211,7 @@ namespace Umbraco.Web.Models.Mapping //if the dest is set and it's the same as the source, then don't change if (dest.DefaultTemplate == null || source.DefaultTemplate != dest.DefaultTemplate.Alias) { - var template = _fileService.GetTemplate(source.DefaultTemplate); + var template = fileService.GetTemplate(source.DefaultTemplate); dest.DefaultTemplate = template == null ? null : Mapper.Map(template); } } diff --git a/src/Umbraco.Web/Models/Mapping/DefaultTemplateResolver.cs b/src/Umbraco.Web/Models/Mapping/DefaultTemplateResolver.cs new file mode 100644 index 0000000000..ae32e7a691 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/DefaultTemplateResolver.cs @@ -0,0 +1,24 @@ +using AutoMapper; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Models.Mapping +{ + internal class DefaultTemplateResolver : IValueResolver + { + public string Resolve(IContent source, ContentItemDisplay destination, string destMember, ResolutionContext context) + { + if (source == null || source.Template == null) return null; + + var alias = source.Template.Alias; + + //set default template if template isn't set + if (string.IsNullOrEmpty(alias)) + alias = source.ContentType.DefaultTemplate == null + ? string.Empty + : source.ContentType.DefaultTemplate.Alias; + + return alias; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs index 27116e8cdc..38440c0b74 100644 --- a/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs @@ -27,7 +27,7 @@ namespace Umbraco.Web.Models.Mapping CreateMap() .ForMember(dest => dest.Udi, opt => opt.MapFrom(src => Udi.Create(ObjectTypes.GetUdiType(src.NodeObjectType), src.Key))) .ForMember(dest => dest.Icon, opt => opt.MapFrom(src => GetContentTypeIcon(src))) - .ForMember(dest => dest.Trashed, opt => opt.Ignore()) + .ForMember(dest => dest.Trashed, opt => opt.MapFrom(src => src.Trashed)) .ForMember(dest => dest.Alias, opt => opt.Ignore()) .AfterMap((src, dest) => { diff --git a/src/Umbraco.Web/Models/Mapping/MediaChildOfListViewResolver.cs b/src/Umbraco.Web/Models/Mapping/MediaChildOfListViewResolver.cs new file mode 100644 index 0000000000..997c900f84 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/MediaChildOfListViewResolver.cs @@ -0,0 +1,26 @@ +using AutoMapper; +using Umbraco.Core.Models; +using Umbraco.Core.Services; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Models.Mapping +{ + internal class MediaChildOfListViewResolver : IValueResolver + { + private readonly IMediaService _mediaService; + private readonly IMediaTypeService _mediaTypeService; + + public MediaChildOfListViewResolver(IMediaService mediaService, IMediaTypeService mediaTypeService) + { + _mediaService = mediaService; + _mediaTypeService = mediaTypeService; + } + + public bool Resolve(IMedia source, MediaItemDisplay destination, bool destMember, ResolutionContext context) + { + // map the IsChildOfListView (this is actually if it is a descendant of a list view!) + var parent = _mediaService.GetParent(source); + return parent != null && (parent.ContentType.IsContainer || _mediaTypeService.HasContainerInPath(parent.Path)); + } + } +} diff --git a/src/Umbraco.Web/Models/Mapping/MediaMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/MediaMapperProfile.cs index 6d3ae36759..540f1d509d 100644 --- a/src/Umbraco.Web/Models/Mapping/MediaMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/MediaMapperProfile.cs @@ -1,14 +1,9 @@ -using System.Collections.Generic; -using System.Linq; -using System.Web; -using System.Web.Mvc; -using AutoMapper; +using AutoMapper; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Services; -using Umbraco.Web.Composing; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Trees; @@ -19,11 +14,14 @@ namespace Umbraco.Web.Models.Mapping /// internal class MediaMapperProfile : Profile { - public MediaMapperProfile(IUserService userService, ILocalizedTextService textService, IDataTypeService dataTypeService, IMediaService mediaService, ILogger logger) + public MediaMapperProfile(IUserService userService, ILocalizedTextService textService, IDataTypeService dataTypeService, IMediaService mediaService, IMediaTypeService mediaTypeService, ILogger logger) { // create, capture, cache var mediaOwnerResolver = new OwnerResolver(userService); - var tabsAndPropertiesResolver = new TabsAndPropertiesResolver(textService); + var tabsAndPropertiesResolver = new TabsAndPropertiesResolver(textService); + var childOfListViewResolver = new MediaChildOfListViewResolver(mediaService, mediaTypeService); + var contentTreeNodeUrlResolver = new ContentTreeNodeUrlResolver(); + var mediaTypeBasicResolver = new ContentTypeBasicResolver(); //FROM IMedia TO MediaItemDisplay CreateMap() @@ -31,20 +29,26 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.Owner, opt => opt.ResolveUsing(src => mediaOwnerResolver.Resolve(src))) .ForMember(dest => dest.Icon, opt => opt.MapFrom(content => content.ContentType.Icon)) .ForMember(dest => dest.ContentTypeAlias, opt => opt.MapFrom(content => content.ContentType.Alias)) - .ForMember(dest => dest.IsChildOfListView, opt => opt.Ignore()) + .ForMember(dest => dest.IsChildOfListView, opt => opt.ResolveUsing(childOfListViewResolver)) .ForMember(dest => dest.Trashed, opt => opt.MapFrom(content => content.Trashed)) .ForMember(dest => dest.ContentTypeName, opt => opt.MapFrom(content => content.ContentType.Name)) .ForMember(dest => dest.Properties, opt => opt.Ignore()) - .ForMember(dest => dest.TreeNodeUrl, opt => opt.Ignore()) + .ForMember(dest => dest.TreeNodeUrl, opt => opt.ResolveUsing(contentTreeNodeUrlResolver)) .ForMember(dest => dest.Notifications, opt => opt.Ignore()) .ForMember(dest => dest.Errors, opt => opt.Ignore()) .ForMember(dest => dest.Published, opt => opt.Ignore()) .ForMember(dest => dest.Updater, opt => opt.Ignore()) .ForMember(dest => dest.Alias, opt => opt.Ignore()) .ForMember(dest => dest.IsContainer, opt => opt.Ignore()) - .ForMember(dest => dest.Tabs, opt => opt.ResolveUsing(src => tabsAndPropertiesResolver.Resolve(src))) + .ForMember(dest => dest.Tabs, opt => opt.ResolveUsing(tabsAndPropertiesResolver)) .ForMember(dest => dest.AdditionalData, opt => opt.Ignore()) - .AfterMap((src, dest) => AfterMap(src, dest, dataTypeService, textService, logger, mediaService)); + .ForMember(dest => dest.ContentType, opt => opt.ResolveUsing(mediaTypeBasicResolver)) + .ForMember(dest => dest.MediaLink, opt => opt.ResolveUsing(content => string.Join(",", content.GetUrls(UmbracoConfig.For.UmbracoSettings().Content, logger)))) + .AfterMap((media, display) => + { + if (media.ContentType.IsContainer) + TabsAndPropertiesResolver.AddListView(display, "media", dataTypeService, textService); + }); //FROM IMedia TO ContentItemBasic CreateMap>() @@ -68,74 +72,5 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.Alias, opt => opt.Ignore()) .ForMember(dest => dest.AdditionalData, opt => opt.Ignore()); } - - private static void AfterMap(IMedia media, MediaItemDisplay display, IDataTypeService dataTypeService, ILocalizedTextService localizedText, ILogger logger, IMediaService mediaService) - { - // Adapted from ContentModelMapper - //map the IsChildOfListView (this is actually if it is a descendant of a list view!) - var parent = media.Parent(); - display.IsChildOfListView = parent != null && (parent.ContentType.IsContainer || Current.Services.ContentTypeService.HasContainerInPath(parent.Path)); - - //map the tree node url - if (HttpContext.Current != null) - { - var urlHelper = new UrlHelper(HttpContext.Current.Request.RequestContext); - var url = urlHelper.GetUmbracoApiService(controller => controller.GetTreeNode(display.Id.ToString(), null)); - display.TreeNodeUrl = url; - } - - if (media.ContentType.IsContainer) - { - TabsAndPropertiesResolver.AddListView(display, "media", dataTypeService, localizedText); - } - - var genericProperties = new List - { - new ContentPropertyDisplay - { - Alias = string.Format("{0}doctype", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("content/mediatype"), - Value = localizedText.UmbracoDictionaryTranslate(display.ContentTypeName), - View = Current.PropertyEditors[Constants.PropertyEditors.Aliases.NoEdit].GetValueEditor().View - } - }; - - TabsAndPropertiesResolver.MapGenericProperties(media, display, localizedText, genericProperties, properties => - { - if (HttpContext.Current != null && UmbracoContext.Current != null && UmbracoContext.Current.Security.CurrentUser != null - && UmbracoContext.Current.Security.CurrentUser.AllowedSections.Any(x => x.Equals(Constants.Applications.Settings))) - { - var mediaTypeLink = string.Format("#/settings/mediatypes/edit/{0}", media.ContentTypeId); - - //Replace the doctype property - var docTypeProperty = properties.First(x => x.Alias == string.Format("{0}doctype", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - docTypeProperty.Value = new List - { - new - { - linkText = media.ContentType.Name, - url = mediaTypeLink, - target = "_self", - icon = "icon-item-arrangement" - } - }; - docTypeProperty.View = "urllist"; - } - - // inject 'Link to media' as the first generic property - var links = media.GetUrls(UmbracoConfig.For.UmbracoSettings().Content, logger); - if (links.Any()) - { - var link = new ContentPropertyDisplay - { - Alias = string.Format("{0}urls", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("media/urls"), - Value = string.Join(",", links), - View = "urllist" - }; - properties.Insert(0, link); - } - }); - } } } diff --git a/src/Umbraco.Web/Models/Mapping/MemberBasicPropertiesResolver.cs b/src/Umbraco.Web/Models/Mapping/MemberBasicPropertiesResolver.cs new file mode 100644 index 0000000000..09d0657530 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/MemberBasicPropertiesResolver.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Models.Mapping +{ + /// + /// A resolver to map properties to a collection of + /// + internal class MemberBasicPropertiesResolver : IValueResolver> + { + public IEnumerable Resolve(IMember source, MemberBasic destination, IEnumerable destMember, ResolutionContext context) + { + var umbracoContext = context.GetUmbracoContext(); + + var result = Mapper.Map, IEnumerable>( + // Sort properties so items from different compositions appear in correct order (see U4-9298). Map sorted properties. + source.Properties.OrderBy(prop => prop.PropertyType.SortOrder)) + .ToList(); + + var memberType = source.ContentType; + + //now update the IsSensitive value + foreach (var prop in result) + { + //check if this property is flagged as sensitive + var isSensitiveProperty = memberType.IsSensitiveProperty(prop.Alias); + //check permissions for viewing sensitive data + if (isSensitiveProperty && umbracoContext.Security.CurrentUser.HasAccessToSensitiveData() == false) + { + //mark this property as sensitive + prop.IsSensitive = true; + //clear the value + prop.Value = null; + } + } + return result; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/Mapping/MemberMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/MemberMapperProfile.cs index 24f5b5ebfd..eac93657f4 100644 --- a/src/Umbraco.Web/Models/Mapping/MemberMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/MemberMapperProfile.cs @@ -1,19 +1,11 @@ using System; -using System.Collections.Generic; -using System.Web; -using System.Web.Mvc; using System.Web.Security; using AutoMapper; using Umbraco.Core; using Umbraco.Core.Models; -using Umbraco.Core.Models.Membership; using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; -using System.Linq; -using Umbraco.Core.Security; using Umbraco.Core.Services.Implement; -using Umbraco.Web.Composing; -using Umbraco.Web.Trees; namespace Umbraco.Web.Models.Mapping { @@ -26,18 +18,15 @@ namespace Umbraco.Web.Models.Mapping { // create, capture, cache var memberOwnerResolver = new OwnerResolver(userService); - var tabsAndPropertiesResolver = new MemberTabsAndPropertiesResolver(textService); + var tabsAndPropertiesResolver = new MemberTabsAndPropertiesResolver(textService, memberService, userService); var memberProfiderFieldMappingResolver = new MemberProviderFieldResolver(); - var membershipScenarioMappingResolver = new MembershipScenarioResolver(new Lazy(() => memberTypeService)); + var membershipScenarioMappingResolver = new MembershipScenarioResolver(memberTypeService); var memberDtoPropertiesResolver = new MemberDtoPropertiesResolver(); + var memberTreeNodeUrlResolver = new MemberTreeNodeUrlResolver(); + var memberBasicPropertiesResolver = new MemberBasicPropertiesResolver(); //FROM MembershipUser TO MediaItemDisplay - used when using a non-umbraco membership provider - CreateMap() - .ConvertUsing(user => - { - var member = Mapper.Map(user); - return Mapper.Map(member); - }); + CreateMap().ConvertUsing(); //FROM MembershipUser TO IMember - used when using a non-umbraco membership provider CreateMap() @@ -75,7 +64,7 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.ContentTypeAlias, opt => opt.MapFrom(src => src.ContentType.Alias)) .ForMember(dest => dest.ContentTypeName, opt => opt.MapFrom(src => src.ContentType.Name)) .ForMember(dest => dest.Properties, opt => opt.Ignore()) - .ForMember(dest => dest.Tabs, opt => opt.ResolveUsing(src => tabsAndPropertiesResolver.Resolve(src))) + .ForMember(dest => dest.Tabs, opt => opt.ResolveUsing(tabsAndPropertiesResolver)) .ForMember(dest => dest.MemberProviderFieldMapping, opt => opt.ResolveUsing(src => memberProfiderFieldMappingResolver.Resolve(src))) .ForMember(dest => dest.MembershipScenario, opt => opt.ResolveUsing(src => membershipScenarioMappingResolver.Resolve(src))) .ForMember(dest => dest.Notifications, opt => opt.Ignore()) @@ -86,8 +75,7 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.IsChildOfListView, opt => opt.Ignore()) .ForMember(dest => dest.Trashed, opt => opt.Ignore()) .ForMember(dest => dest.IsContainer, opt => opt.Ignore()) - .ForMember(dest => dest.TreeNodeUrl, opt => opt.Ignore()) - .AfterMap((src, dest) => MapGenericCustomProperties(memberService, userService, src, dest, textService)); + .ForMember(dest => dest.TreeNodeUrl, opt => opt.ResolveUsing(memberTreeNodeUrlResolver)); //FROM IMember TO MemberBasic CreateMap() @@ -100,7 +88,8 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.Trashed, opt => opt.Ignore()) .ForMember(dest => dest.Published, opt => opt.Ignore()) .ForMember(dest => dest.Updater, opt => opt.Ignore()) - .ForMember(dest => dest.Alias, opt => opt.Ignore()); + .ForMember(dest => dest.Alias, opt => opt.Ignore()) + .ForMember(dto => dto.Properties, expression => expression.ResolveUsing(memberBasicPropertiesResolver)); //FROM MembershipUser TO MemberBasic CreateMap() @@ -110,7 +99,7 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.CreateDate, opt => opt.MapFrom(src => src.CreationDate)) .ForMember(dest => dest.UpdateDate, opt => opt.MapFrom(src => src.LastActivityDate)) .ForMember(dest => dest.Key, opt => opt.MapFrom(src => src.ProviderUserKey.TryConvertTo().Result.ToString("N"))) - .ForMember(dest => dest.Owner, opt => opt.UseValue(new ContentEditing.UserProfile {Name = "Admin", UserId = 0})) + .ForMember(dest => dest.Owner, opt => opt.UseValue(new UserProfile {Name = "Admin", UserId = 0})) .ForMember(dest => dest.Icon, opt => opt.UseValue("icon-user")) .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.UserName)) .ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email)) @@ -148,170 +137,5 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.Alias, opt => opt.Ignore()) .ForMember(dest => dest.Path, opt => opt.Ignore()); } - - /// - /// Maps the generic tab with custom properties for content - /// - /// - /// - /// - /// - /// - /// - /// If this is a new entity and there is an approved field then we'll set it to true by default. - /// - private static void MapGenericCustomProperties(IMemberService memberService, IUserService userService, IMember member, MemberDisplay display, ILocalizedTextService localizedText) - { - var membersProvider = Core.Security.MembershipProviderExtensions.GetMembersMembershipProvider(); - - //map the tree node url - if (HttpContext.Current != null) - { - var urlHelper = new UrlHelper(HttpContext.Current.Request.RequestContext); - var url = urlHelper.GetUmbracoApiService(controller => controller.GetTreeNode(display.Key.ToString("N"), null)); - display.TreeNodeUrl = url; - } - - var genericProperties = new List - { - new ContentPropertyDisplay - { - Alias = string.Format("{0}doctype", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("content/membertype"), - Value = localizedText.UmbracoDictionaryTranslate(display.ContentTypeName), - View = Current.PropertyEditors[Constants.PropertyEditors.Aliases.NoEdit].GetValueEditor().View - }, - GetLoginProperty(memberService, member, display, localizedText), - new ContentPropertyDisplay - { - Alias = string.Format("{0}email", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("general/email"), - Value = display.Email, - View = "email", - Validation = {Mandatory = true} - }, - new ContentPropertyDisplay - { - Alias = string.Format("{0}password", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("password"), - //NOTE: The value here is a json value - but the only property we care about is the generatedPassword one if it exists, the newPassword exists - // only when creating a new member and we want to have a generated password pre-filled. - Value = new Dictionary - { - // fixme why ignoreCase, what are we doing here?! - {"generatedPassword", member.GetAdditionalDataValueIgnoreCase("GeneratedPassword", null)}, - {"newPassword", member.GetAdditionalDataValueIgnoreCase("NewPassword", null)}, - }, - //TODO: Hard coding this because the changepassword doesn't necessarily need to be a resolvable (real) property editor - View = "changepassword", - //initialize the dictionary with the configuration from the default membership provider - Config = new Dictionary(membersProvider.GetConfiguration(userService)) - { - //the password change toggle will only be displayed if there is already a password assigned. - {"hasPassword", member.RawPasswordValue.IsNullOrWhiteSpace() == false} - } - }, - new ContentPropertyDisplay - { - Alias = string.Format("{0}membergroup", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("content/membergroup"), - Value = GetMemberGroupValue(display.Username), - View = "membergroups", - Config = new Dictionary {{"IsRequired", true}} - } - }; - - TabsAndPropertiesResolver.MapGenericProperties(member, display, localizedText, genericProperties, properties => - { - if (HttpContext.Current != null && UmbracoContext.Current != null && UmbracoContext.Current.Security.CurrentUser != null - && UmbracoContext.Current.Security.CurrentUser.AllowedSections.Any(x => x.Equals(Constants.Applications.Settings))) - { - var memberTypeLink = string.Format("#/member/memberTypes/edit/{0}", member.ContentTypeId); - - //Replace the doctype property - var docTypeProperty = properties.First(x => x.Alias == string.Format("{0}doctype", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - docTypeProperty.Value = new List - { - new - { - linkText = member.ContentType.Name, - url = memberTypeLink, - target = "_self", - icon = "icon-item-arrangement" - } - }; - docTypeProperty.View = "urllist"; - } - }); - - //check if there's an approval field - var provider = membersProvider as IUmbracoMemberTypeMembershipProvider; - if (member.HasIdentity == false && provider != null) - { - var approvedField = provider.ApprovedPropertyTypeAlias; - var prop = display.Properties.FirstOrDefault(x => x.Alias == approvedField); - if (prop != null) - { - prop.Value = 1; - } - } - } - - /// - /// Returns the login property display field - /// - /// - /// - /// - /// - /// - /// - /// If the membership provider installed is the umbraco membership provider, then we will allow changing the username, however if - /// the membership provider is a custom one, we cannot allow chaning the username because MembershipProvider's do not actually natively - /// allow that. - /// - internal static ContentPropertyDisplay GetLoginProperty(IMemberService memberService, IMember member, MemberDisplay display, ILocalizedTextService localizedText) - { - var prop = new ContentPropertyDisplay - { - Alias = string.Format("{0}login", Constants.PropertyEditors.InternalGenericPropertiesPrefix), - Label = localizedText.Localize("login"), - Value = display.Username - }; - - var scenario = memberService.GetMembershipScenario(); - - //only allow editing if this is a new member, or if the membership provider is the umbraco one - if (member.HasIdentity == false || scenario == MembershipScenario.NativeUmbraco) - { - prop.View = "textbox"; - prop.Validation.Mandatory = true; - } - else - { - prop.View = "readonlyvalue"; - } - return prop; - } - - internal static IDictionary GetMemberGroupValue(string username) - { - var userRoles = username.IsNullOrWhiteSpace() ? null : Roles.GetRolesForUser(username); - - // create a dictionary of all roles (except internal roles) + "false" - var result = Roles.GetAllRoles().Distinct() - // if a role starts with __umbracoRole we won't show it as it's an internal role used for public access - .Where(x => x.StartsWith(Constants.Conventions.Member.InternalRolePrefix) == false) - .ToDictionary(x => x, x => false); - - // if user has no roles, just return the dictionary - if (userRoles == null) return result; - - // else update the dictionary to "true" for the user roles (except internal roles) - foreach (var userRole in userRoles.Where(x => x.StartsWith(Constants.Conventions.Member.InternalRolePrefix) == false)) - result[userRole] = true; - - return result; - } } } diff --git a/src/Umbraco.Web/Models/Mapping/MemberTabsAndPropertiesResolver.cs b/src/Umbraco.Web/Models/Mapping/MemberTabsAndPropertiesResolver.cs index b4bfead5e7..5066c4420e 100644 --- a/src/Umbraco.Web/Models/Mapping/MemberTabsAndPropertiesResolver.cs +++ b/src/Umbraco.Web/Models/Mapping/MemberTabsAndPropertiesResolver.cs @@ -1,7 +1,11 @@ using System.Collections.Generic; using System.Linq; +using System.Web.Security; +using AutoMapper; using Umbraco.Core; +using Umbraco.Core.Composing; using Umbraco.Core.Models; +using Umbraco.Core.Models.Membership; using Umbraco.Core.Security; using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; @@ -16,44 +20,50 @@ namespace Umbraco.Web.Models.Mapping /// This also ensures that the IsLocked out property is readonly when the member is not locked out - this is because /// an admin cannot actually set isLockedOut = true, they can only unlock. /// - internal class MemberTabsAndPropertiesResolver : TabsAndPropertiesResolver + internal class MemberTabsAndPropertiesResolver : TabsAndPropertiesResolver { private readonly ILocalizedTextService _localizedTextService; + private readonly IMemberService _memberService; + private readonly IUserService _userService; - public MemberTabsAndPropertiesResolver(ILocalizedTextService localizedTextService) + public MemberTabsAndPropertiesResolver(ILocalizedTextService localizedTextService, IMemberService memberService, IUserService userService) : base(localizedTextService) { _localizedTextService = localizedTextService; + _memberService = memberService; + _userService = userService; } - public MemberTabsAndPropertiesResolver(ILocalizedTextService localizedTextService, - IEnumerable ignoreProperties) : base(localizedTextService, ignoreProperties) + public MemberTabsAndPropertiesResolver(ILocalizedTextService localizedTextService, IEnumerable ignoreProperties, IMemberService memberService, IUserService userService) + : base(localizedTextService, ignoreProperties) { _localizedTextService = localizedTextService; + _memberService = memberService; + _userService = userService; } - public override IEnumerable> Resolve(IContentBase content) + /// + /// Overriden to deal with custom member properties and permissions. + public override IEnumerable> Resolve(IMember source, MemberDisplay destination, IEnumerable> destMember, ResolutionContext context) { var provider = Core.Security.MembershipProviderExtensions.GetMembersMembershipProvider(); - IgnoreProperties = content.PropertyTypes + IgnoreProperties = source.PropertyTypes .Where(x => x.HasIdentity == false) .Select(x => x.Alias) .ToArray(); - var result = base.Resolve(content).ToArray(); + var resolved = base.Resolve(source, destination, destMember, context); if (provider.IsUmbracoMembershipProvider() == false) { //it's a generic provider so update the locked out property based on our known constant alias - var isLockedOutProperty = result.SelectMany(x => x.Properties).FirstOrDefault(x => x.Alias == Constants.Conventions.Member.IsLockedOut); - if (isLockedOutProperty != null && isLockedOutProperty.Value.ToString() != "1") + var isLockedOutProperty = resolved.SelectMany(x => x.Properties).FirstOrDefault(x => x.Alias == Constants.Conventions.Member.IsLockedOut); + if (isLockedOutProperty?.Value != null && isLockedOutProperty.Value.ToString() != "1") { isLockedOutProperty.View = "readonlyvalue"; isLockedOutProperty.Value = _localizedTextService.Localize("general/no"); } - - return result; } else { @@ -62,15 +72,186 @@ namespace Umbraco.Web.Models.Mapping //This is kind of a hack because a developer is supposed to be allowed to set their property editor - would have been much easier // if we just had all of the membeship provider fields on the member table :( // TODO: But is there a way to map the IMember.IsLockedOut to the property ? i dunno. - var isLockedOutProperty = result.SelectMany(x => x.Properties).FirstOrDefault(x => x.Alias == umbracoProvider.LockPropertyTypeAlias); - if (isLockedOutProperty != null && isLockedOutProperty.Value.ToString() != "1") + var isLockedOutProperty = resolved.SelectMany(x => x.Properties).FirstOrDefault(x => x.Alias == umbracoProvider.LockPropertyTypeAlias); + if (isLockedOutProperty?.Value != null && isLockedOutProperty.Value.ToString() != "1") { isLockedOutProperty.View = "readonlyvalue"; isLockedOutProperty.Value = _localizedTextService.Localize("general/no"); } - - return result; } + + var umbracoContext = context.GetUmbracoContext(); + if (umbracoContext != null + && umbracoContext.Security.CurrentUser != null + && umbracoContext.Security.CurrentUser.AllowedSections.Any(x => x.Equals(Constants.Applications.Settings))) + { + var memberTypeLink = string.Format("#/member/memberTypes/edit/{0}", source.ContentTypeId); + + //Replace the doctype property + var docTypeProperty = resolved.SelectMany(x => x.Properties) + .First(x => x.Alias == string.Format("{0}doctype", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + docTypeProperty.Value = new List + { + new + { + linkText = source.ContentType.Name, + url = memberTypeLink, + target = "_self", + icon = "icon-item-arrangement" + } + }; + docTypeProperty.View = "urllist"; + } + + return resolved; + } + + protected override IEnumerable GetCustomGenericProperties(IContentBase content) + { + var member = (IMember) content; + var membersProvider = Core.Security.MembershipProviderExtensions.GetMembersMembershipProvider(); + + var genericProperties = new List + { + new ContentPropertyDisplay + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}doctype", + Label = _localizedTextService.Localize("content/membertype"), + Value = _localizedTextService.UmbracoDictionaryTranslate(member.ContentType.Name), + View = Current.PropertyEditors[Constants.PropertyEditors.Aliases.NoEdit].GetValueEditor().View + }, + GetLoginProperty(_memberService, member, _localizedTextService), + new ContentPropertyDisplay + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email", + Label = _localizedTextService.Localize("general/email"), + Value = member.Email, + View = "email", + Validation = {Mandatory = true} + }, + new ContentPropertyDisplay + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password", + Label = _localizedTextService.Localize("password"), + //NOTE: The value here is a json value - but the only property we care about is the generatedPassword one if it exists, the newPassword exists + // only when creating a new member and we want to have a generated password pre-filled. + Value = new Dictionary + { + // fixme why ignoreCase, what are we doing here?! + {"generatedPassword", member.GetAdditionalDataValueIgnoreCase("GeneratedPassword", null)}, + {"newPassword", member.GetAdditionalDataValueIgnoreCase("NewPassword", null)}, + }, + //TODO: Hard coding this because the changepassword doesn't necessarily need to be a resolvable (real) property editor + View = "changepassword", + //initialize the dictionary with the configuration from the default membership provider + Config = new Dictionary(membersProvider.GetConfiguration(_userService)) + { + //the password change toggle will only be displayed if there is already a password assigned. + {"hasPassword", member.RawPasswordValue.IsNullOrWhiteSpace() == false} + } + }, + new ContentPropertyDisplay + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}membergroup", + Label = _localizedTextService.Localize("content/membergroup"), + Value = GetMemberGroupValue(member.Username), + View = "membergroups", + Config = new Dictionary {{"IsRequired", true}} + } + }; + + return genericProperties; + } + + /// + /// Overridden to assign the IsSensitive property values + /// + /// + /// + /// + /// + protected override List MapProperties(UmbracoContext umbracoContext, IContentBase content, List properties) + { + var result = base.MapProperties(umbracoContext, content, properties); + var member = (IMember)content; + var memberType = member.ContentType; + + //now update the IsSensitive value + foreach (var prop in result) + { + //check if this property is flagged as sensitive + var isSensitiveProperty = memberType.IsSensitiveProperty(prop.Alias); + //check permissions for viewing sensitive data + if (isSensitiveProperty && umbracoContext.Security.CurrentUser.HasAccessToSensitiveData() == false) + { + //mark this property as sensitive + prop.IsSensitive = true; + //mark this property as readonly so that it does not post any data + prop.Readonly = true; + //replace this editor with a sensitivevalue + prop.View = "sensitivevalue"; + //clear the value + prop.Value = null; + } + } + return result; + } + + /// + /// Returns the login property display field + /// + /// + /// + /// + /// + /// + /// + /// If the membership provider installed is the umbraco membership provider, then we will allow changing the username, however if + /// the membership provider is a custom one, we cannot allow chaning the username because MembershipProvider's do not actually natively + /// allow that. + /// + internal static ContentPropertyDisplay GetLoginProperty(IMemberService memberService, IMember member, ILocalizedTextService localizedText) + { + var prop = new ContentPropertyDisplay + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login", + Label = localizedText.Localize("login"), + Value = member.Username + }; + + var scenario = memberService.GetMembershipScenario(); + + //only allow editing if this is a new member, or if the membership provider is the umbraco one + if (member.HasIdentity == false || scenario == MembershipScenario.NativeUmbraco) + { + prop.View = "textbox"; + prop.Validation.Mandatory = true; + } + else + { + prop.View = "readonlyvalue"; + } + return prop; + } + + internal static IDictionary GetMemberGroupValue(string username) + { + var userRoles = username.IsNullOrWhiteSpace() ? null : Roles.GetRolesForUser(username); + + // create a dictionary of all roles (except internal roles) + "false" + var result = Roles.GetAllRoles().Distinct() + // if a role starts with __umbracoRole we won't show it as it's an internal role used for public access + .Where(x => x.StartsWith(Constants.Conventions.Member.InternalRolePrefix) == false) + .ToDictionary(x => x, x => false); + + // if user has no roles, just return the dictionary + if (userRoles == null) return result; + + // else update the dictionary to "true" for the user roles (except internal roles) + foreach (var userRole in userRoles.Where(x => x.StartsWith(Constants.Conventions.Member.InternalRolePrefix) == false)) + result[userRole] = true; + + return result; } } } diff --git a/src/Umbraco.Web/Models/Mapping/MemberTreeNodeUrlResolver.cs b/src/Umbraco.Web/Models/Mapping/MemberTreeNodeUrlResolver.cs new file mode 100644 index 0000000000..864fd18ab2 --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/MemberTreeNodeUrlResolver.cs @@ -0,0 +1,23 @@ +using System.Web.Mvc; +using AutoMapper; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; +using Umbraco.Web.Trees; + +namespace Umbraco.Web.Models.Mapping +{ + /// + /// Gets the tree node url for the IMember + /// + internal class MemberTreeNodeUrlResolver : IValueResolver + { + public string Resolve(IMember source, MemberDisplay destination, string destMember, ResolutionContext context) + { + var umbracoContext = context.GetUmbracoContext(throwIfMissing: false); + if (umbracoContext == null) return null; + + var urlHelper = new UrlHelper(umbracoContext.HttpContext.Request.RequestContext); + return urlHelper.GetUmbracoApiService(controller => controller.GetTreeNode(source.Key.ToString("N"), null)); + } + } +} diff --git a/src/Umbraco.Web/Models/Mapping/MembershipScenarioResolver.cs b/src/Umbraco.Web/Models/Mapping/MembershipScenarioResolver.cs index 98ff68c578..3c3e0af5aa 100644 --- a/src/Umbraco.Web/Models/Mapping/MembershipScenarioResolver.cs +++ b/src/Umbraco.Web/Models/Mapping/MembershipScenarioResolver.cs @@ -9,9 +9,9 @@ namespace Umbraco.Web.Models.Mapping { internal class MembershipScenarioResolver { - private readonly Lazy _memberTypeService; + private readonly IMemberTypeService _memberTypeService; - public MembershipScenarioResolver(Lazy memberTypeService) + public MembershipScenarioResolver(IMemberTypeService memberTypeService) { _memberTypeService = memberTypeService; } @@ -24,7 +24,7 @@ namespace Umbraco.Web.Models.Mapping { return MembershipScenario.NativeUmbraco; } - var memberType = _memberTypeService.Value.Get(Constants.Conventions.MemberTypes.DefaultAlias); + var memberType = _memberTypeService.Get(Constants.Conventions.MemberTypes.DefaultAlias); return memberType != null ? MembershipScenario.CustomProviderWithUmbracoLink : MembershipScenario.StandaloneCustomProvider; diff --git a/src/Umbraco.Web/Models/Mapping/MembershipUserTypeConverter.cs b/src/Umbraco.Web/Models/Mapping/MembershipUserTypeConverter.cs new file mode 100644 index 0000000000..dbbf6f69df --- /dev/null +++ b/src/Umbraco.Web/Models/Mapping/MembershipUserTypeConverter.cs @@ -0,0 +1,21 @@ +using System.Web.Security; +using AutoMapper; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Models.Mapping +{ + /// + /// A converter to go from a to a + /// + internal class MembershipUserTypeConverter : ITypeConverter + { + public MemberDisplay Convert(MembershipUser source, MemberDisplay destination, ResolutionContext context) + { + //first convert to IMember + var member = Mapper.Map(source); + //then convert to MemberDisplay + return ContextMapper.Map(member, context.GetUmbracoContext()); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/Mapping/DashboardMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/RedirectUrlMapperProfile.cs similarity index 65% rename from src/Umbraco.Web/Models/Mapping/DashboardMapperProfile.cs rename to src/Umbraco.Web/Models/Mapping/RedirectUrlMapperProfile.cs index aa2608875b..e92e72db77 100644 --- a/src/Umbraco.Web/Models/Mapping/DashboardMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/RedirectUrlMapperProfile.cs @@ -1,18 +1,16 @@ using AutoMapper; -using Umbraco.Web.Models.ContentEditing; using Umbraco.Core.Models; +using Umbraco.Web.Composing; +using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Models.Mapping { - /// - /// A model mapper used to map models for the various dashboards - /// - internal class DashboardMapperProfile : Profile + internal class RedirectUrlMapperProfile : Profile { - public DashboardMapperProfile() + public RedirectUrlMapperProfile() { CreateMap() - .ForMember(x => x.OriginalUrl, expression => expression.MapFrom(item => UmbracoContext.Current.UrlProvider.GetUrlFromRoute(item.ContentId, item.Url))) + .ForMember(x => x.OriginalUrl, expression => expression.MapFrom(item => Current.UmbracoContext.UrlProvider.GetUrlFromRoute(item.ContentId, item.Url))) .ForMember(x => x.DestinationUrl, expression => expression.Ignore()) .ForMember(x => x.RedirectId, expression => expression.MapFrom(item => item.Key)); } diff --git a/src/Umbraco.Web/Models/Mapping/TabsAndPropertiesResolver.cs b/src/Umbraco.Web/Models/Mapping/TabsAndPropertiesResolver.cs index 696837c669..cf596c1761 100644 --- a/src/Umbraco.Web/Models/Mapping/TabsAndPropertiesResolver.cs +++ b/src/Umbraco.Web/Models/Mapping/TabsAndPropertiesResolver.cs @@ -10,17 +10,14 @@ using Umbraco.Web.Composing; namespace Umbraco.Web.Models.Mapping { - /// - /// Creates the tabs collection with properties assigned for display models - /// - internal class TabsAndPropertiesResolver + internal abstract class TabsAndPropertiesResolver { - private readonly ILocalizedTextService _localizedTextService; + protected ILocalizedTextService LocalizedTextService { get; } protected IEnumerable IgnoreProperties { get; set; } public TabsAndPropertiesResolver(ILocalizedTextService localizedTextService) { - _localizedTextService = localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + LocalizedTextService = localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); IgnoreProperties = new List(); } @@ -29,87 +26,7 @@ namespace Umbraco.Web.Models.Mapping { IgnoreProperties = ignoreProperties ?? throw new ArgumentNullException(nameof(ignoreProperties)); } - - /// - /// Maps properties on to the generic properties tab - /// - /// - /// - /// - /// - /// Any additional custom properties to assign to the generic properties tab. - /// - /// - /// - /// The generic properties tab is mapped during AfterMap and is responsible for - /// setting up the properties such as Created date, updated date, template selected, etc... - /// - public static void MapGenericProperties( - TPersisted content, - ContentItemDisplayBase display, - ILocalizedTextService localizedTextService, - IEnumerable customProperties = null, - Action> onGenericPropertiesMapped = null) - where TPersisted : IContentBase - { - var genericProps = display.Tabs.Single(x => x.Id == 0); - - //store the current props to append to the newly inserted ones - var currProps = genericProps.Properties.ToArray(); - - var labelEditor = Current.PropertyEditors[Constants.PropertyEditors.Aliases.NoEdit].GetValueEditor().View; - - var contentProps = new List - { - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}id", - Label = "Id", - Value = Convert.ToInt32(display.Id).ToInvariantString() + "
" + display.Key + "", - View = labelEditor - }, - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}creator", - Label = localizedTextService.Localize("content/createBy"), - Description = localizedTextService.Localize("content/createByDesc"), - Value = display.Owner.Name, - View = labelEditor - }, - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}createdate", - Label = localizedTextService.Localize("content/createDate"), - Description = localizedTextService.Localize("content/createDateDesc"), - Value = display.CreateDate.ToIsoString(), - View = labelEditor - }, - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}updatedate", - Label = localizedTextService.Localize("content/updateDate"), - Description = localizedTextService.Localize("content/updateDateDesc"), - Value = display.UpdateDate.ToIsoString(), - View = labelEditor - } - }; - - if (customProperties != null) - { - //add the custom ones - contentProps.AddRange(customProperties); - } - - //now add the user props - contentProps.AddRange(currProps); - - //callback - onGenericPropertiesMapped?.Invoke(contentProps); - - //re-assign - genericProps.Properties = contentProps; - } - + /// /// Adds the container (listview) tab to the document /// @@ -136,7 +53,7 @@ namespace Umbraco.Web.Models.Mapping dtdId = Constants.DataTypes.DefaultMembersListView; break; default: - throw new ArgumentOutOfRangeException("entityType does not match a required value"); + throw new ArgumentOutOfRangeException(nameof(entityType), "entityType does not match a required value"); } //first try to get the custom one if there is one @@ -220,15 +137,117 @@ namespace Umbraco.Web.Models.Mapping display.Tabs = tabs; } - public virtual IEnumerable> Resolve(IContentBase content) + /// + /// Returns a collection of custom generic properties that exist on the generic properties tab + /// + /// + protected virtual IEnumerable GetCustomGenericProperties(IContentBase content) { + return Enumerable.Empty(); + } + + /// + /// Maps properties on to the generic properties tab + /// + /// + /// + /// + /// + /// The generic properties tab is responsible for + /// setting up the properties such as Created date, updated date, template selected, etc... + /// + protected virtual void MapGenericProperties(UmbracoContext umbracoContext, IContentBase content, List> tabs) + { + // add the generic properties tab, for properties that don't belong to a tab + // get the properties, map and translate them, then add the tab + var noGroupProperties = content.GetNonGroupedProperties() + .Where(x => IgnoreProperties.Contains(x.Alias) == false) // skip ignored + .ToList(); + var genericproperties = MapProperties(umbracoContext, content, noGroupProperties); + + tabs.Add(new Tab + { + Id = 0, + Label = LocalizedTextService.Localize("general/properties"), + Alias = "Generic properties", + Properties = genericproperties + }); + + var genericProps = tabs.Single(x => x.Id == 0); + + //store the current props to append to the newly inserted ones + var currProps = genericProps.Properties.ToArray(); + + var contentProps = new List(); + + var customProperties = GetCustomGenericProperties(content); + if (customProperties != null) + { + //add the custom ones + contentProps.AddRange(customProperties); + } + + //now add the user props + contentProps.AddRange(currProps); + + //re-assign + genericProps.Properties = contentProps; + + //Show or hide properties tab based on wether it has or not any properties + if (genericProps.Properties.Any() == false) + { + //loop throug the tabs, remove the one with the id of zero and exit the loop + for (var i = 0; i < tabs.Count; i++) + { + if (tabs[i].Id != 0) continue; + tabs.RemoveAt(i); + break; + } + } + } + + /// + /// Maps a list of to a list of + /// + /// + /// + /// + /// + protected virtual List MapProperties(UmbracoContext umbracoContext, IContentBase content, List properties) + { + var result = Mapper.Map, IEnumerable>( + // Sort properties so items from different compositions appear in correct order (see U4-9298). Map sorted properties. + properties.OrderBy(prop => prop.PropertyType.SortOrder)) + .ToList(); + + return result; + } + } + + /// + /// Creates the tabs collection with properties assigned for display models + /// + internal class TabsAndPropertiesResolver : TabsAndPropertiesResolver, IValueResolver>> + where TSource : IContentBase + { + public TabsAndPropertiesResolver(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { } + + public TabsAndPropertiesResolver(ILocalizedTextService localizedTextService, IEnumerable ignoreProperties) + : base(localizedTextService, ignoreProperties) + { } + + public virtual IEnumerable> Resolve(TSource source, TDestination destination, IEnumerable> destMember, ResolutionContext context) + { + var umbracoContext = context.GetUmbracoContext(); var tabs = new List>(); // add the tabs, for properties that belong to a tab // need to aggregate the tabs, as content.PropertyGroups contains all the composition tabs, // and there might be duplicates (content does not work like contentType and there is no // content.CompositionPropertyGroups). - var groupsGroupsByName = content.PropertyGroups.OrderBy(x => x.SortOrder).GroupBy(x => x.Name); + var groupsGroupsByName = source.PropertyGroups.OrderBy(x => x.SortOrder).GroupBy(x => x.Name); foreach (var groupsByName in groupsGroupsByName) { var properties = new List(); @@ -236,7 +255,7 @@ namespace Umbraco.Web.Models.Mapping // merge properties for groups with the same name foreach (var group in groupsByName) { - var groupProperties = content.GetPropertiesForGroup(group) + var groupProperties = source.GetPropertiesForGroup(group) .Where(x => IgnoreProperties.Contains(x.Alias) == false); // skip ignored properties.AddRange(groupProperties); @@ -245,14 +264,12 @@ namespace Umbraco.Web.Models.Mapping if (properties.Count == 0) continue; - // Sort properties so items from different compositions appear in correct order (see U4-9298). Map sorted properties. - var mappedProperties = Mapper.Map, IEnumerable>(properties.OrderBy(prop => prop.PropertyType.SortOrder)); - - TranslateProperties(mappedProperties); + //map the properties + var mappedProperties = MapProperties(umbracoContext, source, properties); // add the tab // we need to pick an identifier... there is no "right" way... - var g = groupsByName.FirstOrDefault(x => x.Id == content.ContentTypeId) // try local + var g = groupsByName.FirstOrDefault(x => x.Id == source.ContentTypeId) // try local ?? groupsByName.First(); // else pick one randomly var groupId = g.Id; var groupName = groupsByName.Key; @@ -260,41 +277,19 @@ namespace Umbraco.Web.Models.Mapping { Id = groupId, Alias = groupName, - Label = _localizedTextService.UmbracoDictionaryTranslate(groupName), + Label = LocalizedTextService.UmbracoDictionaryTranslate(groupName), Properties = mappedProperties, IsActive = false }); } - // add the generic properties tab, for properties that don't belong to a tab - // get the properties, map and translate them, then add the tab - var noGroupProperties = content.GetNonGroupedProperties() - .Where(x => IgnoreProperties.Contains(x.Alias) == false); // skip ignored - var genericproperties = Mapper.Map, IEnumerable>(noGroupProperties).ToList(); - TranslateProperties(genericproperties); + MapGenericProperties(umbracoContext, source, tabs); - tabs.Add(new Tab - { - Id = 0, - Label = _localizedTextService.Localize("general/properties"), - Alias = "Generic properties", - Properties = genericproperties - }); - - // activate the first tab - tabs.First().IsActive = true; + // activate the first tab, if any + if (tabs.Count > 0) + tabs[0].IsActive = true; return tabs; } - - private void TranslateProperties(IEnumerable properties) - { - // Not sure whether it's a good idea to add this to the ContentPropertyDisplay mapper - foreach (var prop in properties) - { - prop.Label = _localizedTextService.UmbracoDictionaryTranslate(prop.Label); - prop.Description = _localizedTextService.UmbracoDictionaryTranslate(prop.Description); - } - } } } diff --git a/src/Umbraco.Web/Models/Mapping/UserMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/UserMapperProfile.cs index f02defa031..83a2fffdb2 100644 --- a/src/Umbraco.Web/Models/Mapping/UserMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/UserMapperProfile.cs @@ -26,7 +26,7 @@ namespace Umbraco.Web.Models.Mapping var userGroupDefaultPermissionsResolver = new UserGroupDefaultPermissionsResolver(textService, actions); CreateMap() - .ConstructUsing((UserGroupSave save) => new UserGroup { CreateDate = DateTime.Now }) + .ConstructUsing(save => new UserGroup { CreateDate = DateTime.UtcNow }) .IgnoreEntityCommonProperties() .ForMember(dest => dest.Id, opt => opt.Condition(source => GetIntId(source.Id) > 0)) .ForMember(dest => dest.Id, opt => opt.MapFrom(source => GetIntId(source.Id))) @@ -44,6 +44,7 @@ namespace Umbraco.Web.Models.Mapping CreateMap() .IgnoreEntityCommonProperties() .ForMember(dest => dest.Id, opt => opt.Condition(src => GetIntId(src.Id) > 0)) + .ForMember(detail => detail.TourData, opt => opt.Ignore()) .ForMember(dest => dest.SessionTimeout, opt => opt.Ignore()) .ForMember(dest => dest.EmailConfirmedDate, opt => opt.Ignore()) .ForMember(dest => dest.UserType, opt => opt.Ignore()) @@ -75,6 +76,7 @@ namespace Umbraco.Web.Models.Mapping CreateMap() .IgnoreEntityCommonProperties() .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(detail => detail.TourData, opt => opt.Ignore()) .ForMember(dest => dest.StartContentIds, opt => opt.Ignore()) .ForMember(dest => dest.StartMediaIds, opt => opt.Ignore()) .ForMember(dest => dest.UserType, opt => opt.Ignore()) @@ -200,9 +202,22 @@ namespace Umbraco.Web.Models.Mapping var allContentPermissions = userService.GetPermissions(@group, true) .ToDictionary(x => x.EntityId, x => x); - var contentEntities = allContentPermissions.Keys.Count == 0 - ? Array.Empty() - : entityService.GetAll(UmbracoObjectTypes.Document, allContentPermissions.Keys.ToArray()); + IEntitySlim[] contentEntities; + if (allContentPermissions.Keys.Count == 0) + { + contentEntities = Array.Empty(); + } + else + { + // a group can end up with way more than 2000 assigned permissions, + // so we need to break them into groups in order to avoid breaking + // the entity service due to too many Sql parameters. + + var list = new List(); + foreach (var idGroup in allContentPermissions.Keys.InGroupsOf(2000)) + list.AddRange(entityService.GetAll(UmbracoObjectTypes.Document, idGroup.ToArray())); + contentEntities = list.ToArray(); + } var allAssignedPermissions = new List(); foreach (var entity in contentEntities) @@ -229,10 +244,11 @@ namespace Umbraco.Web.Models.Mapping //Important! Currently we are never mapping to multiple UserDisplay objects but if we start doing that // this will cause an N+1 and we'll need to change how this works. CreateMap() - .ForMember(dest => dest.Avatars, opt => opt.MapFrom(user => user.GetCurrentUserAvatarUrls(userService, runtimeCache))) + .ForMember(dest => dest.Avatars, opt => opt.MapFrom(user => user.GetUserAvatarUrls(runtimeCache))) .ForMember(dest => dest.Username, opt => opt.MapFrom(user => user.Username)) .ForMember(dest => dest.LastLoginDate, opt => opt.MapFrom(user => user.LastLoginDate == default(DateTime) ? null : (DateTime?)user.LastLoginDate)) .ForMember(dest => dest.UserGroups, opt => opt.MapFrom(user => user.Groups)) + .ForMember(detail => detail.Navigation, opt => opt.MapFrom(user => CreateUserEditorNavigation(textService))) .ForMember( dest => dest.CalculatedStartContentIds, opt => opt.MapFrom(src => GetStartNodeValues( @@ -278,7 +294,7 @@ namespace Umbraco.Web.Models.Mapping //like the load time is waiting. .ForMember(detail => detail.Avatars, - opt => opt.MapFrom(user => user.GetCurrentUserAvatarUrls(userService, runtimeCache))) + opt => opt.MapFrom(user => user.GetUserAvatarUrls(runtimeCache))) .ForMember(dest => dest.Username, opt => opt.MapFrom(user => user.Username)) .ForMember(dest => dest.UserGroups, opt => opt.MapFrom(user => user.Groups)) .ForMember(dest => dest.LastLoginDate, opt => opt.MapFrom(user => user.LastLoginDate == default(DateTime) ? null : (DateTime?)user.LastLoginDate)) @@ -298,7 +314,7 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.AdditionalData, opt => opt.Ignore()); CreateMap() - .ForMember(dest => dest.Avatars, opt => opt.MapFrom(user => user.GetCurrentUserAvatarUrls(userService, runtimeCache))) + .ForMember(dest => dest.Avatars, opt => opt.MapFrom(user => user.GetUserAvatarUrls(runtimeCache))) .ForMember(dest => dest.UserId, opt => opt.MapFrom(user => GetIntId(user.Id))) .ForMember(dest => dest.StartContentIds, opt => opt.MapFrom(user => user.CalculateContentStartNodeIds(entityService))) .ForMember(dest => dest.StartMediaIds, opt => opt.MapFrom(user => user.CalculateMediaStartNodeIds(entityService))) @@ -340,18 +356,21 @@ namespace Umbraco.Web.Models.Mapping CreateMap() .ForMember(dest => dest.UserId, opt => opt.MapFrom(profile => GetIntId(profile.Id))); + } - CreateMap() - .ConstructUsing((IUser user) => new UserData()) - .ForMember(dest => dest.Id, opt => opt.MapFrom(user => user.Id)) - .ForMember(dest => dest.AllowedApplications, opt => opt.MapFrom(user => user.AllowedSections.ToArray())) - .ForMember(dest => dest.RealName, opt => opt.MapFrom(user => user.Name)) - .ForMember(dest => dest.Roles, opt => opt.MapFrom(user => user.Groups.Select(x => x.Alias).ToArray())) - .ForMember(dest => dest.StartContentNodes, opt => opt.MapFrom(user => user.CalculateContentStartNodeIds(entityService))) - .ForMember(dest => dest.StartMediaNodes, opt => opt.MapFrom(user => user.CalculateMediaStartNodeIds(entityService))) - .ForMember(dest => dest.Username, opt => opt.MapFrom(user => user.Username)) - .ForMember(dest => dest.Culture, opt => opt.MapFrom(user => user.GetUserCulture(textService))) - .ForMember(dest => dest.SessionId, opt => opt.MapFrom(user => user.SecurityStamp.IsNullOrWhiteSpace() ? Guid.NewGuid().ToString("N") : user.SecurityStamp)); + private IEnumerable CreateUserEditorNavigation(ILocalizedTextService textService) + { + return new[] + { + new EditorNavigation + { + Active = true, + Alias = "details", + Icon = "icon-umb-users", + Name = textService.Localize("general/user"), + View = "views/users/views/user/details.html" + } + }; } private IEnumerable GetStartNodeValues(int[] startNodeIds, diff --git a/src/Umbraco.Web/Models/Trees/ExportMember.cs b/src/Umbraco.Web/Models/Trees/ExportMember.cs new file mode 100644 index 0000000000..66a10da007 --- /dev/null +++ b/src/Umbraco.Web/Models/Trees/ExportMember.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Web.Models.Trees +{ + /// + /// Represents the export member menu item + /// + [ActionMenuItem("umbracoMenuActions")] + public sealed class ExportMember : ActionMenuItem + { } +} diff --git a/src/Umbraco.Web/Models/Trees/MenuItem.cs b/src/Umbraco.Web/Models/Trees/MenuItem.cs index 43da07f11e..003743043b 100644 --- a/src/Umbraco.Web/Models/Trees/MenuItem.cs +++ b/src/Umbraco.Web/Models/Trees/MenuItem.cs @@ -192,7 +192,7 @@ namespace Umbraco.Web.Models.Trees else { // if that doesn't work, try to get the legacy confirm view - var attempt2 = LegacyTreeDataConverter.GetLegacyConfirmView(Action, currentSection); + var attempt2 = LegacyTreeDataConverter.GetLegacyConfirmView(Action); if (attempt2) { var view = attempt2.Result; diff --git a/src/Umbraco.Web/Models/Trees/MenuItemList.cs b/src/Umbraco.Web/Models/Trees/MenuItemList.cs index 26fb0d2796..2e38939f1e 100644 --- a/src/Umbraco.Web/Models/Trees/MenuItemList.cs +++ b/src/Umbraco.Web/Models/Trees/MenuItemList.cs @@ -30,7 +30,7 @@ namespace Umbraco.Web.Models.Trees /// The text to display for the menu item, will default to the IAction alias if not specified internal MenuItem Add(IAction action, string name) { - var item = new MenuItem(action); + var item = new MenuItem(action, name); DetectLegacyActionMenu(action.GetType(), item); diff --git a/src/Umbraco.Web/Models/UserTourStatus.cs b/src/Umbraco.Web/Models/UserTourStatus.cs new file mode 100644 index 0000000000..d1834f3d6b --- /dev/null +++ b/src/Umbraco.Web/Models/UserTourStatus.cs @@ -0,0 +1,60 @@ +using System; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models +{ + /// + /// A model representing the tours a user has taken/completed + /// + [DataContract(Name = "userTourStatus", Namespace = "")] + public class UserTourStatus : IEquatable + { + /// + /// The tour alias + /// + [DataMember(Name = "alias")] + public string Alias { get; set; } + + /// + /// If the tour is completed + /// + [DataMember(Name = "completed")] + public bool Completed { get; set; } + + /// + /// If the tour is disabled + /// + [DataMember(Name = "disabled")] + public bool Disabled { get; set; } + + public bool Equals(UserTourStatus other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return string.Equals(Alias, other.Alias); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((UserTourStatus) obj); + } + + public override int GetHashCode() + { + return Alias.GetHashCode(); + } + + public static bool operator ==(UserTourStatus left, UserTourStatus right) + { + return Equals(left, right); + } + + public static bool operator !=(UserTourStatus left, UserTourStatus right) + { + return !Equals(left, right); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Mvc/ContentModelBinder.cs b/src/Umbraco.Web/Mvc/ContentModelBinder.cs index 33ea5f84f8..b68d5a36f7 100644 --- a/src/Umbraco.Web/Mvc/ContentModelBinder.cs +++ b/src/Umbraco.Web/Mvc/ContentModelBinder.cs @@ -16,6 +16,11 @@ namespace Umbraco.Web.Mvc /// public class ContentModelBinder : DefaultModelBinder, IModelBinderProvider { + // use Instance + private ContentModelBinder() { } + + public static ContentModelBinder Instance = new ContentModelBinder(); + /// /// Binds the model to a value by using the specified controller context and binding context. /// @@ -119,6 +124,7 @@ namespace Umbraco.Web.Mvc { var msg = new StringBuilder(); + // prepare message msg.Append("Cannot bind source"); if (sourceContent) msg.Append(" content"); msg.Append(" type "); @@ -129,22 +135,17 @@ namespace Umbraco.Web.Mvc msg.Append(modelType.FullName); msg.Append("."); - // compare FullName for the time being because when upgrading ModelsBuilder, - // Umbraco does not know about the new attribute type - later on, can compare - // on type directly (ie after v7.4.2). - var sourceAttr = sourceType.Assembly.CustomAttributes.FirstOrDefault(x => - x.AttributeType.FullName == "Umbraco.ModelsBuilder.PureLiveAssemblyAttribute"); - var modelAttr = modelType.Assembly.CustomAttributes.FirstOrDefault(x => - x.AttributeType.FullName == "Umbraco.ModelsBuilder.PureLiveAssemblyAttribute"); +// raise event, to give model factories a chance at reporting + // the error with more details, and optionally request that + // the application restarts. - // bah.. names are App_Web_all.generated.cs.8f9494c4.jjuvxz55 so they ARE different, fuck! - // we cannot compare purely on type.FullName 'cos we might be trying to map Sub to Main = fails! - if (sourceAttr != null && modelAttr != null - && sourceType.Assembly.GetName().Version.Revision != modelType.Assembly.GetName().Version.Revision) + var args = new ModelBindingArgs(sourceType, modelType, msg); + ModelBindingException?.Invoke(Instance, args); + + if (args.Restart) { - msg.Append(" Types come from two PureLive assemblies with different versions,"); - msg.Append(" this usually indicates that the application is in an unstable state."); - msg.Append(" The application is restarting now, reload the page and it should work."); + msg.Append(" The application is restarting now."); + var context = HttpContext.Current; if (context == null) AppDomain.Unload(AppDomain.CurrentDomain); @@ -167,5 +168,47 @@ namespace Umbraco.Web.Mvc if (typeof(IPublishedContent).IsAssignableFrom(modelType)) return this; return null; } + + /// + /// Contains event data for the event. + /// + public class ModelBindingArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + public ModelBindingArgs(Type sourceType, Type modelType, StringBuilder message) + { + SourceType = sourceType; + ModelType = modelType; + Message = message; + } + + /// + /// Gets the type of the source object. + /// + public Type SourceType { get; set; } + + /// + /// Gets the type of the view model. + /// + public Type ModelType { get; set; } + + /// + /// Gets the message string builder. + /// + /// Handlers of the event can append text to the message. + public StringBuilder Message { get; } + + /// + /// Gets or sets a value indicating whether the application should restart. + /// + public bool Restart { get; set; } + } + + /// + /// Occurs on model binding exceptions. + /// + public static event EventHandler ModelBindingException; } } diff --git a/src/Umbraco.Web/Mvc/RenderRouteHandler.cs b/src/Umbraco.Web/Mvc/RenderRouteHandler.cs index b9446d3aa7..b9bb71c42c 100644 --- a/src/Umbraco.Web/Mvc/RenderRouteHandler.cs +++ b/src/Umbraco.Web/Mvc/RenderRouteHandler.cs @@ -33,10 +33,8 @@ namespace Umbraco.Web.Mvc // fixme - that one could / should accept a PublishedRouter (engine) to work on the PublishedRequest (published content request) public RenderRouteHandler(IUmbracoContextAccessor umbracoContextAccessor, IControllerFactory controllerFactory) { - if (umbracoContextAccessor == null) throw new ArgumentNullException(nameof(umbracoContextAccessor)); - if (controllerFactory == null) throw new ArgumentNullException(nameof(controllerFactory)); - _umbracoContextAccessor = umbracoContextAccessor; - _controllerFactory = controllerFactory; + _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + _controllerFactory = controllerFactory ?? throw new ArgumentNullException(nameof(controllerFactory)); } // fixme - what about that one? @@ -45,10 +43,8 @@ namespace Umbraco.Web.Mvc // UmbracoComponentRenderer - ?? that one is not so obvious public RenderRouteHandler(UmbracoContext umbracoContext, IControllerFactory controllerFactory) { - if (umbracoContext == null) throw new ArgumentNullException(nameof(umbracoContext)); - if (controllerFactory == null) throw new ArgumentNullException(nameof(controllerFactory)); - _umbracoContext = umbracoContext; - _controllerFactory = controllerFactory; + _umbracoContext = umbracoContext ?? throw new ArgumentNullException(nameof(umbracoContext)); + _controllerFactory = controllerFactory ?? throw new ArgumentNullException(nameof(controllerFactory)); } private UmbracoContext UmbracoContext => _umbracoContext ?? _umbracoContextAccessor.UmbracoContext; @@ -65,12 +61,12 @@ namespace Umbraco.Web.Mvc { if (UmbracoContext == null) { - throw new NullReferenceException("There is not current UmbracoContext, it must be initialized before the RenderRouteHandler executes"); + throw new NullReferenceException("There is no current UmbracoContext, it must be initialized before the RenderRouteHandler executes"); } var request = UmbracoContext.PublishedRequest; if (request == null) { - throw new NullReferenceException("There is not current PublishedContentRequest, it must be initialized before the RenderRouteHandler executes"); + throw new NullReferenceException("There is no current PublishedContentRequest, it must be initialized before the RenderRouteHandler executes"); } SetupRouteDataForRequest( @@ -394,7 +390,7 @@ namespace Umbraco.Web.Mvc //here we need to check if there is no hijacked route and no template assigned, if this is the case //we want to return a blank page, but we'll leave that up to the NoTemplateHandler. - if (request.HasTemplate == false && routeDef.HasHijackedRoute == false) + if (!request.HasTemplate && !routeDef.HasHijackedRoute && !_features.Enabled.RenderNoTemplate) { // fixme - better find a way to inject that engine? or at least Current.Engine of some sort! var engine = Core.Composing.Current.Container.GetInstance(); @@ -455,7 +451,5 @@ namespace Umbraco.Web.Mvc { return _controllerFactory.GetControllerSessionBehavior(requestContext, controllerName); } - - } } diff --git a/src/Umbraco.Web/Properties/AssemblyInfo.cs b/src/Umbraco.Web/Properties/AssemblyInfo.cs index d1d9e10597..b7d046787e 100644 --- a/src/Umbraco.Web/Properties/AssemblyInfo.cs +++ b/src/Umbraco.Web/Properties/AssemblyInfo.cs @@ -33,6 +33,9 @@ using System.Runtime.InteropServices; [assembly: InternalsVisibleTo("Umbraco.Forms.Core.Providers")] [assembly: InternalsVisibleTo("Umbraco.Forms.Web")] +// Umbraco Headless +[assembly: InternalsVisibleTo("Umbraco.Headless")] + // v8 [assembly: InternalsVisibleTo("Umbraco.Compat7")] diff --git a/src/Umbraco.Web/PublishedContentQuery.cs b/src/Umbraco.Web/PublishedContentQuery.cs index 2e48bf1c55..2a8bedc79f 100644 --- a/src/Umbraco.Web/PublishedContentQuery.cs +++ b/src/Umbraco.Web/PublishedContentQuery.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Xml.XPath; +using Examine.LuceneEngine.Providers; +using Examine.LuceneEngine.SearchCriteria; +using Examine.SearchCriteria; using Umbraco.Core; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.Xml; @@ -27,10 +31,8 @@ namespace Umbraco.Web /// public PublishedContentQuery(IPublishedContentCache contentCache, IPublishedMediaCache mediaCache) { - if (contentCache == null) throw new ArgumentNullException(nameof(contentCache)); - if (mediaCache == null) throw new ArgumentNullException(nameof(mediaCache)); - _contentCache = contentCache; - _mediaCache = mediaCache; + _contentCache = contentCache ?? throw new ArgumentNullException(nameof(contentCache)); + _mediaCache = mediaCache ?? throw new ArgumentNullException(nameof(mediaCache)); } /// @@ -39,8 +41,7 @@ namespace Umbraco.Web /// public PublishedContentQuery(IPublishedContentQuery query) { - if (query == null) throw new ArgumentNullException(nameof(query)); - _query = query; + _query = query ?? throw new ArgumentNullException(nameof(query)); } #region Content @@ -217,43 +218,104 @@ namespace Umbraco.Web #region Search - /// - /// Searches content - /// - /// - /// - /// - /// + /// public IEnumerable Search(string term, bool useWildCards = true, string searchProvider = null) { - if (_query != null) return _query.Search(term, useWildCards, searchProvider); + return Search(0, 0, out _, term, useWildCards, searchProvider); + } - var searcher = Examine.ExamineManager.Instance.DefaultSearchProvider; - if (string.IsNullOrEmpty(searchProvider) == false) - searcher = Examine.ExamineManager.Instance.SearchProviderCollection[searchProvider]; + /// + public IEnumerable Search(int skip, int take, out int totalRecords, string term, bool useWildCards = true, string searchProvider = null) + { + if (_query != null) return _query.Search(skip, take, out totalRecords, term, useWildCards, searchProvider); - var results = searcher.Search(term, useWildCards); + var searcher = string.IsNullOrWhiteSpace(searchProvider) + ? Examine.ExamineManager.Instance.DefaultSearchProvider + : Examine.ExamineManager.Instance.SearchProviderCollection[searchProvider]; + + if (skip == 0 && take == 0) + { + var results = searcher.Search(term, useWildCards); + totalRecords = results.TotalItemCount; + return results.ToPublishedSearchResults(_contentCache); + } + + if (!(searcher is BaseLuceneSearcher luceneSearcher)) + { + var results = searcher.Search(term, useWildCards); + totalRecords = results.TotalItemCount; + // Examine skip, Linq take + return results.Skip(skip).ToPublishedSearchResults(_contentCache).Take(take); + } + + var criteria = SearchAllFields(term, useWildCards, luceneSearcher); + return Search(skip, take, out totalRecords, criteria, searcher); + } + + /// + public IEnumerable Search(Examine.SearchCriteria.ISearchCriteria criteria, Examine.Providers.BaseSearchProvider searchProvider = null) + { + return Search(0, 0, out _, criteria, searchProvider); + } + + /// + public IEnumerable Search(int skip, int take, out int totalRecords, Examine.SearchCriteria.ISearchCriteria criteria, Examine.Providers.BaseSearchProvider searchProvider = null) + { + if (_query != null) return _query.Search(skip, take, out totalRecords, criteria, searchProvider); + + var searcher = searchProvider ?? Examine.ExamineManager.Instance.DefaultSearchProvider; + + var results = skip == 0 && take == 0 + ? searcher.Search(criteria) + : searcher.Search(criteria, maxResults: skip + take); + + totalRecords = results.TotalItemCount; return results.ToPublishedSearchResults(_contentCache); } /// - /// Searhes content + /// Creates an ISearchCriteria for searching all fields in a . /// - /// - /// - /// - public IEnumerable Search(Examine.SearchCriteria.ISearchCriteria criteria, Examine.Providers.BaseSearchProvider searchProvider = null) + /// + /// This is here because some of this stuff is internal in Examine. + /// + private ISearchCriteria SearchAllFields(string searchText, bool useWildcards, BaseLuceneSearcher searcher) { - if (_query != null) return _query.Search(criteria, searchProvider); + var sc = searcher.CreateSearchCriteria(); - var s = Examine.ExamineManager.Instance.DefaultSearchProvider; - if (searchProvider != null) - s = searchProvider; + if (_examineGetSearchFields == null) + { + //get the GetSearchFields method from BaseLuceneSearcher + _examineGetSearchFields = typeof(BaseLuceneSearcher).GetMethod("GetSearchFields", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + } - var results = s.Search(criteria); - return results.ToPublishedSearchResults(_contentCache); + //get the results of searcher.BaseLuceneSearcher() using ugly reflection since it's not public + var searchFields = (IEnumerable) _examineGetSearchFields.Invoke(searcher, null); + + //this is what Examine does internally to create ISearchCriteria for searching all fields + var strArray = searchText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + sc = useWildcards == false + ? sc.GroupedOr(searchFields, strArray).Compile() + : sc.GroupedOr(searchFields, strArray.Select(x => new CustomExamineValue(Examineness.ComplexWildcard, x.MultipleCharacterWildcard().Value)).ToArray()).Compile(); + return sc; } + private static MethodInfo _examineGetSearchFields; + + //support class since Examine doesn't expose it's own ExamineValue class publicly + private class CustomExamineValue : IExamineValue + { + public CustomExamineValue(Examineness vagueness, string value) + { + this.Examineness = vagueness; + this.Value = value; + this.Level = 1f; + } + public Examineness Examineness { get; private set; } + public string Value { get; private set; } + public float Level { get; private set; } + } + #endregion } } diff --git a/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs b/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs index 6cbed8dc6e..e2a213c954 100644 --- a/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs +++ b/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Configuration; +using System.IO; using System.Linq; using System.Web; using System.Web.Configuration; @@ -9,6 +10,7 @@ using System.Web.Http; using System.Web.Http.Dispatcher; using System.Web.Mvc; using System.Web.Routing; +using ClientDependency.Core.CompositeFiles.Providers; using ClientDependency.Core.Config; using LightInject; using Microsoft.AspNet.SignalR; @@ -41,6 +43,7 @@ using Umbraco.Web.Search; using Umbraco.Web.Security; using Umbraco.Web.Services; using Umbraco.Web.SignalR; +using Umbraco.Web.Tour; using Umbraco.Web.Trees; using Umbraco.Web.UI.JavaScript; using Umbraco.Web.WebApi; @@ -117,6 +120,10 @@ namespace Umbraco.Web.Runtime composition.Container.RegisterCollectionBuilder() .Add(() => typeLoader.GetTypes()); + composition.Container.RegisterCollectionBuilder(); + + composition.Container.RegisterCollectionBuilder(); // fixme FeaturesResolver? + // set the default RenderMvcController Current.DefaultRenderMvcControllerType = typeof(RenderMvcController); // fixme WRONG! @@ -202,23 +209,8 @@ namespace Umbraco.Web.Runtime // setup mvc and webapi services SetupMvcAndWebApi(); - // Backwards compatibility - set the path and URL type for ClientDependency 1.5.1 [LK] - ClientDependency.Core.CompositeFiles.Providers.XmlFileMapper.FileMapVirtualFolder = "~/App_Data/TEMP/ClientDependency"; - ClientDependency.Core.CompositeFiles.Providers.BaseCompositeFileProcessingProvider.UrlTypeDefault = ClientDependency.Core.CompositeFiles.Providers.CompositeUrlType.Base64QueryStrings; - - if (ConfigurationManager.GetSection("system.web/httpRuntime") is HttpRuntimeSection section) - { - //set the max url length for CDF to be the smallest of the max query length, max request length - ClientDependency.Core.CompositeFiles.CompositeDependencyHandler.MaxHandlerUrlLength = Math.Min(section.MaxQueryStringLength, section.MaxRequestLength); - } - - //Register a custom renderer - used to process property editor dependencies - var renderer = new DependencyPathRenderer(); - renderer.Initialize("Umbraco.DependencyPathRenderer", new NameValueCollection - { - { "compositeFileHandlerPath", ClientDependencySettings.Instance.CompositeFileHandlerPath } - }); - ClientDependencySettings.Instance.MvcRendererCollection.Add(renderer); + // client dependency + ConfigureClientDependency(); // Disable the X-AspNetMvc-Version HTTP Header MvcHandler.DisableMvcResponseHeader = true; @@ -372,7 +364,7 @@ namespace Umbraco.Web.Runtime ViewEngines.Engines.Add(new PluginViewEngine()); //set model binder - ModelBinderProviders.BinderProviders.Add(new ContentModelBinder()); // is a provider + ModelBinderProviders.BinderProviders.Add(ContentModelBinder.Instance); // is a provider ////add the profiling action filter //GlobalFilters.Filters.Add(new ProfilingActionFilter()); @@ -380,5 +372,44 @@ namespace Umbraco.Web.Runtime GlobalConfiguration.Configuration.Services.Replace(typeof(IHttpControllerSelector), new NamespaceHttpControllerSelector(GlobalConfiguration.Configuration)); } + + private static void ConfigureClientDependency() + { + // Backwards compatibility - set the path and URL type for ClientDependency 1.5.1 [LK] + XmlFileMapper.FileMapDefaultFolder = "~/App_Data/TEMP/ClientDependency"; + BaseCompositeFileProcessingProvider.UrlTypeDefault = CompositeUrlType.Base64QueryStrings; + + // Now we need to detect if we are running umbracoLocalTempStorage as EnvironmentTemp and in that case we want to change the CDF file + // location to be there + if (GlobalSettings.LocalTempStorageLocation == LocalTempStorage.EnvironmentTemp) + { + var appDomainHash = HttpRuntime.AppDomainAppId.ToSHA1(); + var cachePath = Path.Combine(Environment.ExpandEnvironmentVariables("%temp%"), "UmbracoData", + //include the appdomain hash is just a safety check, for example if a website is moved from worker A to worker B and then back + // to worker A again, in theory the %temp% folder should already be empty but we really want to make sure that its not + // utilizing an old path + appDomainHash); + + //set the file map and composite file default location to the %temp% location + BaseCompositeFileProcessingProvider.CompositeFilePathDefaultFolder + = XmlFileMapper.FileMapDefaultFolder + = Path.Combine(cachePath, "ClientDependency"); + } + + if (ConfigurationManager.GetSection("system.web/httpRuntime") is HttpRuntimeSection section) + { + //set the max url length for CDF to be the smallest of the max query length, max request length + ClientDependency.Core.CompositeFiles.CompositeDependencyHandler.MaxHandlerUrlLength = Math.Min(section.MaxQueryStringLength, section.MaxRequestLength); + } + + //Register a custom renderer - used to process property editor dependencies + var renderer = new DependencyPathRenderer(); + renderer.Initialize("Umbraco.DependencyPathRenderer", new NameValueCollection + { + { "compositeFileHandlerPath", ClientDependencySettings.Instance.CompositeFileHandlerPath } + }); + + ClientDependencySettings.Instance.MvcRendererCollection.Add(renderer); + } } } diff --git a/src/Umbraco.Web/Scheduling/KeepAlive.cs b/src/Umbraco.Web/Scheduling/KeepAlive.cs index 5221d8b499..74c9f79d42 100644 --- a/src/Umbraco.Web/Scheduling/KeepAlive.cs +++ b/src/Umbraco.Web/Scheduling/KeepAlive.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Umbraco.Core; using Umbraco.Core.Logging; +using Umbraco.Core.Sync; namespace Umbraco.Web.Scheduling { @@ -24,6 +25,17 @@ namespace Umbraco.Web.Scheduling public override async Task PerformRunAsync(CancellationToken token) { + // not on slaves nor unknown role servers + switch (_runtime.ServerRole) + { + case ServerRole.Slave: + _logger.Debug("Does not run on slave servers."); + return true; // role may change! + case ServerRole.Unknown: + _logger.Debug("Does not run on servers with unknown role."); + return true; // role may change! + } + // ensure we do not run if not main domain, but do NOT lock it if (_runtime.IsMainDom == false) { diff --git a/src/Umbraco.Web/Scheduling/LatchedBackgroundTaskBase.cs b/src/Umbraco.Web/Scheduling/LatchedBackgroundTaskBase.cs index 11db8da4c8..1c86b873bb 100644 --- a/src/Umbraco.Web/Scheduling/LatchedBackgroundTaskBase.cs +++ b/src/Umbraco.Web/Scheduling/LatchedBackgroundTaskBase.cs @@ -5,7 +5,7 @@ using Umbraco.Core; namespace Umbraco.Web.Scheduling { - public abstract class LatchedBackgroundTaskBase : DisposableObject, ILatchedBackgroundTask + public abstract class LatchedBackgroundTaskBase : DisposableObjectSlim, ILatchedBackgroundTask { private TaskCompletionSource _latch; diff --git a/src/Umbraco.Web/Scheduling/ScheduledPublishing.cs b/src/Umbraco.Web/Scheduling/ScheduledPublishing.cs index 582e540499..5b0ca2ee1b 100644 --- a/src/Umbraco.Web/Scheduling/ScheduledPublishing.cs +++ b/src/Umbraco.Web/Scheduling/ScheduledPublishing.cs @@ -1,6 +1,4 @@ using System; -using System.Threading; -using System.Threading.Tasks; using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Publishing; @@ -24,7 +22,7 @@ namespace Umbraco.Web.Scheduling _logger = logger; } - public override async Task PerformRunAsync(CancellationToken token) + public override bool PerformRun() { if (Suspendable.ScheduledPublishing.CanRun == false) return true; // repeat, later @@ -46,15 +44,21 @@ namespace Umbraco.Web.Scheduling return false; // do NOT repeat, going down } + // do NOT run publishing if not properly running + if (_runtime.Level != RuntimeLevel.Run) + { + _logger.Debug("Does not run if run level is not Run."); + return true; // repeat/wait + } + try { - // DO not run publishing if content is re-loading - if (_runtime.Level != RuntimeLevel.Run) - { - var publisher = new ScheduledPublisher(_contentService, _logger); - var count = publisher.CheckPendingAndProcess(); - _logger.Warn("No url for service (yet), skip."); - } + // run + // fixme context & events during scheduled publishing? + // in v7 we create an UmbracoContext and an HttpContext, and cache instructions + // are batched, and we have to explicitely flush them, how is it going to work here? + var publisher = new ScheduledPublisher(_contentService, _logger); + var count = publisher.CheckPendingAndProcess(); } catch (Exception e) { @@ -64,6 +68,6 @@ namespace Umbraco.Web.Scheduling return true; // repeat } - public override bool IsAsync => true; + public override bool IsAsync => false; } } diff --git a/src/Umbraco.Web/Scheduling/ScheduledTasks.cs b/src/Umbraco.Web/Scheduling/ScheduledTasks.cs index 86d8efc9dd..dc0f77e0fc 100644 --- a/src/Umbraco.Web/Scheduling/ScheduledTasks.cs +++ b/src/Umbraco.Web/Scheduling/ScheduledTasks.cs @@ -67,6 +67,9 @@ namespace Umbraco.Web.Scheduling { using (var wc = new HttpClient()) { + // url could be relative, so better set a base url for the http client + wc.BaseAddress = _runtime.ApplicationUrl; + var request = new HttpRequestMessage(HttpMethod.Get, url); //TODO: pass custom the authorization header, currently these aren't really secured! diff --git a/src/Umbraco.Web/Scheduling/SchedulerComponent.cs b/src/Umbraco.Web/Scheduling/SchedulerComponent.cs index 50ba87968b..3cadb1d43e 100644 --- a/src/Umbraco.Web/Scheduling/SchedulerComponent.cs +++ b/src/Umbraco.Web/Scheduling/SchedulerComponent.cs @@ -66,15 +66,16 @@ namespace Umbraco.Web.Scheduling _healthCheckRunner = new BackgroundTaskRunner("HealthCheckNotifier", logger); // we will start the whole process when a successful request is made - UmbracoModule.RouteAttempt += UmbracoModuleRouteAttempt; + UmbracoModule.RouteAttempt += RegisterBackgroundTasksOnce; } - private void UmbracoModuleRouteAttempt(object sender, RoutableAttemptEventArgs e) + private void RegisterBackgroundTasksOnce(object sender, RoutableAttemptEventArgs e) { switch (e.Outcome) { case EnsureRoutableOutcome.IsRoutable: case EnsureRoutableOutcome.NotDocumentRequest: + UmbracoModule.RouteAttempt -= RegisterBackgroundTasksOnce; RegisterBackgroundTasks(); break; } @@ -82,9 +83,6 @@ namespace Umbraco.Web.Scheduling private void RegisterBackgroundTasks() { - // remove handler, we're done - UmbracoModule.RouteAttempt -= UmbracoModuleRouteAttempt; - LazyInitializer.EnsureInitialized(ref _tasks, ref _started, ref _locker, () => { _logger.Debug(() => "Initializing the scheduler"); diff --git a/src/Umbraco.Web/Tour/BackOfficeTourFilter.cs b/src/Umbraco.Web/Tour/BackOfficeTourFilter.cs new file mode 100644 index 0000000000..bcca2e6673 --- /dev/null +++ b/src/Umbraco.Web/Tour/BackOfficeTourFilter.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; + +namespace Umbraco.Web.Tour +{ + /// + /// Represents a back-office tour filter. + /// + public class BackOfficeTourFilter + { + /// + /// Initializes a new instance of the class. + /// + /// Value to filter out tours by a plugin, can be null + /// Value to filter out a tour file, can be null + /// Value to filter out a tour alias, can be null + /// + /// Depending on what is null will depend on how the filter is applied. + /// If pluginName is not NULL and it's matched then we check if tourFileName is not NULL and it's matched then we check tour alias is not NULL and then match it, + /// if any steps is NULL then the filters upstream are applied. + /// Example, pluginName = "hello", tourFileName="stuff", tourAlias=NULL = we will filter out the tour file "stuff" from the plugin "hello" but not from other plugins if the same file name exists. + /// Example, tourAlias="test.*" = we will filter out all tour aliases that start with the word "test" regardless of the plugin or file name + /// + public BackOfficeTourFilter(Regex pluginName, Regex tourFileName, Regex tourAlias) + { + PluginName = pluginName; + TourFileName = tourFileName; + TourAlias = tourAlias; + } + + /// + /// Gets the plugin name filtering regex. + /// + public Regex PluginName { get; } + + /// + /// Gets the tour filename filtering regex. + /// + public Regex TourFileName { get; } + + /// + /// Gets the tour alias filtering regex. + /// + public Regex TourAlias { get; } + + /// + /// Creates a filter to filter on the plugin name. + /// + public static BackOfficeTourFilter FilterPlugin(Regex pluginName) + => new BackOfficeTourFilter(pluginName, null, null); + + /// + /// Creates a filter to filter on the tour filename. + /// + public static BackOfficeTourFilter FilterFile(Regex tourFileName) + => new BackOfficeTourFilter(null, tourFileName, null); + + /// + /// Creates a filter to filter on the tour alias. + /// + public static BackOfficeTourFilter FilterAlias(Regex tourAlias) + => new BackOfficeTourFilter(null, null, tourAlias); + } +} diff --git a/src/Umbraco.Web/Tour/TourFilterCollection.cs b/src/Umbraco.Web/Tour/TourFilterCollection.cs new file mode 100644 index 0000000000..e221c17170 --- /dev/null +++ b/src/Umbraco.Web/Tour/TourFilterCollection.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Umbraco.Core.Composing; + +namespace Umbraco.Web.Tour +{ + /// + /// Represents a collection of items. + /// + public class TourFilterCollection : BuilderCollectionBase + { + /// + /// Initializes a new instance of the class. + /// + public TourFilterCollection(IEnumerable items) + : base(items) + { } + } +} diff --git a/src/Umbraco.Web/Tour/TourFilterCollectionBuilder.cs b/src/Umbraco.Web/Tour/TourFilterCollectionBuilder.cs new file mode 100644 index 0000000000..5084975bbd --- /dev/null +++ b/src/Umbraco.Web/Tour/TourFilterCollectionBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using LightInject; +using Umbraco.Core; +using Umbraco.Core.Composing; + +namespace Umbraco.Web.Tour +{ + /// + /// Builds a collection of items. + /// + public class TourFilterCollectionBuilder : CollectionBuilderBase + { + private readonly HashSet _instances = new HashSet(); + + /// + /// Initializes a new instance of the class. + /// + public TourFilterCollectionBuilder(IServiceContainer container) + : base(container) + { } + + /// + protected override IEnumerable CreateItems(params object[] args) + { + return base.CreateItems(args).Concat(_instances); + } + + /// + /// Adds a filter instance. + /// + public void AddFilter(BackOfficeTourFilter filter) + { + _instances.Add(filter); + } + + /// + /// Removes a filter instance. + /// + public void RemoveFilter(BackOfficeTourFilter filter) + { + _instances.Remove(filter); + } + + /// + /// Removes all filter instances. + /// + public void RemoveAllFilters() + { + _instances.Clear(); + } + + /// + /// Removes filters matching a condition. + /// + public void RemoveFilter(Func predicate) + { + _instances.RemoveWhere(new Predicate(predicate)); + } + + /// + /// Creates and adds a filter instance filtering by plugin name. + /// + public void AddFilterByPlugin(string pluginName) + { + pluginName = pluginName.EnsureStartsWith("^").EnsureEndsWith("$"); + _instances.Add(BackOfficeTourFilter.FilterPlugin(new Regex(pluginName, RegexOptions.IgnoreCase))); + } + + /// + /// Creates and adds a filter instance filtering by tour filename. + /// + public void AddFilterByFile(string filename) + { + filename = filename.EnsureStartsWith("^").EnsureEndsWith("$"); + _instances.Add(BackOfficeTourFilter.FilterFile(new Regex(filename, RegexOptions.IgnoreCase))); + } + } +} diff --git a/src/Umbraco.Web/Trees/ApplicationTreeController.cs b/src/Umbraco.Web/Trees/ApplicationTreeController.cs index 0ab6a4ec75..a37bd08c8b 100644 --- a/src/Umbraco.Web/Trees/ApplicationTreeController.cs +++ b/src/Umbraco.Web/Trees/ApplicationTreeController.cs @@ -82,10 +82,19 @@ namespace Umbraco.Web.Trees private async Task GetRootForMultipleAppTree(ApplicationTree configTree, FormDataCollection queryStrings) { if (configTree == null) throw new ArgumentNullException(nameof(configTree)); - var byControllerAttempt = await configTree.TryGetRootNodeFromControllerTree(queryStrings, ControllerContext); - if (byControllerAttempt.Success) + try { - return byControllerAttempt.Result; + var byControllerAttempt = await configTree.TryGetRootNodeFromControllerTree(queryStrings, ControllerContext); + if (byControllerAttempt.Success) + { + return byControllerAttempt.Result; + } + } + catch (HttpResponseException) + { + //if this occurs its because the user isn't authorized to view that tree, in this case since we are loading multiple trees we + //will just return null so that it's not added to the list. + return null; } var legacyAttempt = configTree.TryGetRootNodeFromLegacyTree(queryStrings, Url, configTree.ApplicationAlias); diff --git a/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs b/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs index a4e62551fe..dd27c80382 100644 --- a/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs +++ b/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs @@ -32,12 +32,18 @@ namespace Umbraco.Web.Trees public TreeNode GetTreeNode(string id, FormDataCollection queryStrings) { int asInt; + Guid asGuid = Guid.Empty; if (int.TryParse(id, out asInt) == false) { - throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); + if (Guid.TryParse(id, out asGuid) == false) + { + throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); + } } - var entity = Services.EntityService.Get(asInt, UmbracoObjectType); + var entity = asGuid == Guid.Empty + ? Services.EntityService.Get(asInt, UmbracoObjectType) + : Services.EntityService.Get(asGuid, UmbracoObjectType); if (entity == null) { throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); @@ -112,102 +118,92 @@ namespace Umbraco.Web.Trees { var nodes = new TreeNodeCollection(); - var altStartId = string.Empty; - if (queryStrings.HasKey(TreeQueryStringParameters.StartNodeId)) - altStartId = queryStrings.GetValue(TreeQueryStringParameters.StartNodeId); var rootIdString = Constants.System.Root.ToString(CultureInfo.InvariantCulture); + var hasAccessToRoot = UserStartNodes.Contains(Constants.System.Root); - //check if a request has been made to render from a specific start node - if (string.IsNullOrEmpty(altStartId) == false && altStartId != "undefined" && altStartId != rootIdString) + var startNodeId = queryStrings.HasKey(TreeQueryStringParameters.StartNodeId) + ? queryStrings.GetValue(TreeQueryStringParameters.StartNodeId) + : string.Empty; + + if (string.IsNullOrEmpty(startNodeId) == false && startNodeId != "undefined" && startNodeId != rootIdString) { - id = altStartId; + // request has been made to render from a specific, non-root, start node + id = startNodeId; - //we need to verify that the user has access to view this node, otherwise we'll render an empty tree collection + // ensure that the user has access to that node, otherwise return the empty tree nodes collection // TODO: in the future we could return a validation statement so we can have some UI to notify the user they don't have access if (HasPathAccess(id, queryStrings) == false) { - Logger.Warn("The user " + Security.CurrentUser.Username + " does not have access to the tree node " + id); - return new TreeNodeCollection(); + Logger.Warn("User " + Security.CurrentUser.Username + " does not have access to node with id " + id); + return nodes; } - // So there's an alt id specified, it's not the root node and the user has access to it, great! But there's one thing we - // need to consider: - // If the tree is being rendered in a dialog view we want to render only the children of the specified id, but - // when the tree is being rendered normally in a section and the current user's start node is not -1, then - // we want to include their start node in the tree as well. - // Therefore, in the latter case, we want to change the id to -1 since we want to render the current user's root node - // and the GetChildEntities method will take care of rendering the correct root node. - // If it is in dialog mode, then we don't need to change anything and the children will just render as per normal. - if (IsDialog(queryStrings) == false && UserStartNodes.Contains(Constants.System.Root) == false) - { - id = Constants.System.Root.ToString(CultureInfo.InvariantCulture); - } + // if the tree is rendered... + // - in a dialog: render only the children of the specific start node, nothing to do + // - in a section: if the current user's start nodes do not contain the root node, we need + // to include these start nodes in the tree too, to provide some context - i.e. change + // start node back to root node, and then GetChildEntities method will take care of the rest. + if (IsDialog(queryStrings) == false && hasAccessToRoot == false) + id = rootIdString; } + // get child entities - if id is root, but user's start nodes do not contain the + // root node, this returns the start nodes instead of root's children var entities = GetChildEntities(id).ToList(); + nodes.AddRange(entities.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings)).Where(x => x != null)); - //If we are looking up the root and there is more than one node ... - //then we want to lookup those nodes' 'site' nodes and render those so that the - //user has some context of where they are in the tree, this is generally for pickers in a dialog. - //for any node they don't have access too, we need to add some metadata - if (id == rootIdString && entities.Count > 1) + // if the user does not have access to the root node, what we have is the start nodes, + // but to provide some context we also need to add their topmost nodes when they are not + // topmost nodes themselves (level > 1). + if (id == rootIdString && hasAccessToRoot == false) { - var siteNodeIds = new List(); - //put into array since we might modify the list - foreach (var e in entities.ToArray()) + var topNodeIds = entities.Where(x => x.Level > 1).Select(GetTopNodeId).Where(x => x != 0).Distinct().ToArray(); + if (topNodeIds.Length > 0) { - var pathParts = e.Path.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); - if (pathParts.Length < 2) - continue; // this should never happen but better to check - - int siteNodeId; - if (int.TryParse(pathParts[1], out siteNodeId) == false) - continue; - - //we'll look up this - siteNodeIds.Add(siteNodeId); + var topNodes = Services.EntityService.GetAll(UmbracoObjectType, topNodeIds.ToArray()); + nodes.AddRange(topNodes.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings)).Where(x => x != null)); } - var siteNodes = Services.EntityService.GetAll(UmbracoObjectType, siteNodeIds.ToArray()) - .DistinctBy(e => e.Id) - .ToArray(); - - //add site nodes - nodes.AddRange(siteNodes.Select(e => GetSingleTreeNodeWithAccessCheck(e, id, queryStrings)).Where(node => node != null)); - - return nodes; } - nodes.AddRange(entities.Select(e => GetSingleTreeNodeWithAccessCheck(e, id, queryStrings)).Where(node => node != null)); return nodes; } + private static readonly char[] Comma = { ',' }; + + private int GetTopNodeId(IUmbracoEntity entity) + { + int id; + var parts = entity.Path.Split(Comma, StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 && int.TryParse(parts[1], out id) ? id : 0; + } + protected abstract MenuItemCollection PerformGetMenuForNode(string id, FormDataCollection queryStrings); protected abstract UmbracoObjectTypes UmbracoObjectType { get; } protected IEnumerable GetChildEntities(string id) { - // use helper method to ensure we support both integer and guid lookups - - if (int.TryParse(id, out int iid) == false) + // try to parse id as an integer else use GetEntityFromId + // which will grok Guids, Udis, etc and let use obtain the id + if (int.TryParse(id, out var entityId) == false) { - var idEntity = GetEntityFromId(id); - if (idEntity == null) + var entity = GetEntityFromId(id); + if (entity == null) throw new HttpResponseException(HttpStatusCode.NotFound); - iid = idEntity.Id; + entityId = entity.Id; } // if a request is made for the root node but user has no access to // root node, return start nodes instead - if (iid == Constants.System.Root && UserStartNodes.Contains(Constants.System.Root) == false) + if (entityId == Constants.System.Root && UserStartNodes.Contains(Constants.System.Root) == false) { return UserStartNodes.Length > 0 ? Services.EntityService.GetAll(UmbracoObjectType, UserStartNodes) : Enumerable.Empty(); } - return Services.EntityService.GetChildren(iid, UmbracoObjectType).ToArray(); + return Services.EntityService.GetChildren(entityId, UmbracoObjectType).ToArray(); } /// diff --git a/src/Umbraco.Web/Trees/LegacyTreeDataConverter.cs b/src/Umbraco.Web/Trees/LegacyTreeDataConverter.cs index 4ab67224df..c4710ea12d 100644 --- a/src/Umbraco.Web/Trees/LegacyTreeDataConverter.cs +++ b/src/Umbraco.Web/Trees/LegacyTreeDataConverter.cs @@ -141,7 +141,7 @@ namespace Umbraco.Web.Trees else { // if that doesn't work, try to get the legacy confirm view - var attempt2 = GetLegacyConfirmView(currentAction, currentSection); + var attempt2 = GetLegacyConfirmView(currentAction); if (attempt2) { var view = attempt2.Result; @@ -177,9 +177,8 @@ namespace Umbraco.Web.Trees /// This will look at the legacy IAction's JsFunctionName and convert it to a confirmation dialog view if possible /// /// - /// /// - internal static Attempt GetLegacyConfirmView(IAction action, string currentSection) + internal static Attempt GetLegacyConfirmView(IAction action) { if (action.JsFunctionName.IsNullOrWhiteSpace()) { diff --git a/src/Umbraco.Web/Trees/MacroTreeController.cs b/src/Umbraco.Web/Trees/MacroTreeController.cs new file mode 100644 index 0000000000..5e0a8fa25a --- /dev/null +++ b/src/Umbraco.Web/Trees/MacroTreeController.cs @@ -0,0 +1,73 @@ +using System; +using System.Net.Http.Formatting; +using umbraco; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Web.Models.Trees; +using Umbraco.Web.Mvc; +using System.Linq; +using umbraco.BusinessLogic.Actions; +using Umbraco.Web.WebApi.Filters; +using Umbraco.Core.Services; +using Constants = Umbraco.Core.Constants; + +namespace Umbraco.Web.Trees +{ + [UmbracoTreeAuthorize(Constants.Trees.Macros)] + [Tree(Constants.Applications.Developer, Constants.Trees.Macros, null, sortOrder: 2)] + [LegacyBaseTree(typeof(loadMacros))] + [PluginController("UmbracoTrees")] + [CoreTree] + public class MacroTreeController : TreeController + { + protected override TreeNodeCollection GetTreeNodes(string id, FormDataCollection queryStrings) + { + var intId = id.TryConvertTo(); + if (intId == false) throw new InvalidOperationException("Id must be an integer"); + + var nodes = new TreeNodeCollection(); + + nodes.AddRange( + Services.MacroService.GetAll() + .OrderBy(entity => entity.Name) + .Select(macro => + { + var node = CreateTreeNode(macro.Id.ToInvariantString(), id, queryStrings, macro.Name, "icon-settings-alt", false); + node.Path = "-1," + macro.Id; + node.AssignLegacyJsCallback("javascript:UmbClientMgr.contentFrame('developer/macros/editMacro.aspx?macroID=" + macro.Id + "');"); + return node; + })); + + return nodes; + } + + protected override MenuItemCollection GetMenuForNode(string id, FormDataCollection queryStrings) + { + var menu = new MenuItemCollection(); + + if (id == Constants.System.Root.ToInvariantString()) + { + //set the default to create + menu.DefaultMenuAlias = ActionNew.Instance.Alias; + + // root actions + menu.Items.Add(Services.TextService.Localize(string.Format("actions/{0}", ActionNew.Instance.Alias))) + .ConvertLegacyMenuItem(null, Constants.Trees.Macros, queryStrings.GetValue("application")); + + menu.Items.Add(ui.Text("actions", ActionRefresh.Instance.Alias), true); + return menu; + } + + //TODO: This is all hacky ... don't have time to convert the tree, views and dialogs over properly so we'll keep using the legacy dialogs + var menuItem = menu.Items.Add(ActionDelete.Instance, Services.TextService.Localize(string.Format("actions/{0}", ActionDelete.Instance.Alias))); + var legacyConfirmView = LegacyTreeDataConverter.GetLegacyConfirmView(ActionDelete.Instance); + if (legacyConfirmView == false) + throw new InvalidOperationException("Could not resolve the confirmation view for the legacy action " + ActionDelete.Instance.Alias); + menuItem.LaunchDialogView( + legacyConfirmView.Result, + Services.TextService.Localize(string.Format("general/{0}", ActionDelete.Instance.Alias))); + + return menu; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Trees/MemberTreeController.cs b/src/Umbraco.Web/Trees/MemberTreeController.cs index cdf9cf8085..a0dbcee233 100644 --- a/src/Umbraco.Web/Trees/MemberTreeController.cs +++ b/src/Umbraco.Web/Trees/MemberTreeController.cs @@ -17,6 +17,7 @@ using Umbraco.Web._Legacy.Actions; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Search; using Constants = Umbraco.Core.Constants; +using Umbraco.Core.Services; namespace Umbraco.Web.Trees { @@ -177,6 +178,17 @@ namespace Umbraco.Web.Trees //add delete option for all members menu.Items.Add(Services.TextService.Localize("actions", ActionDelete.Instance.Alias)); + if (Security.CurrentUser.HasAccessToSensitiveData()) + { + menu.Items.Add(new ExportMember + { + Name = Services.TextService.Localize("actions/export"), + Icon = "download-alt", + Alias = "export" + }); + } + + return menu; } diff --git a/src/Umbraco.Web/Trees/UserTreeController.cs b/src/Umbraco.Web/Trees/UserTreeController.cs index 3f7a6f8ce7..df50d49555 100644 --- a/src/Umbraco.Web/Trees/UserTreeController.cs +++ b/src/Umbraco.Web/Trees/UserTreeController.cs @@ -1,4 +1,7 @@ using System.Net.Http.Formatting; +using umbraco.BusinessLogic.Actions; +using Umbraco.Core; +using Umbraco.Core.Services; using Umbraco.Web.Models.Trees; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi.Filters; @@ -7,7 +10,7 @@ using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.Trees { [UmbracoTreeAuthorize(Constants.Trees.Users)] - [Tree(Constants.Applications.Users, Constants.Trees.Users, "Users", sortOrder: 0)] + [Tree(Constants.Applications.Users, Constants.Trees.Users, null, sortOrder: 0)] [PluginController("UmbracoTrees")] [CoreTree] public class UserTreeController : TreeController @@ -39,6 +42,31 @@ namespace Umbraco.Web.Trees protected override MenuItemCollection GetMenuForNode(string id, FormDataCollection queryStrings) { var menu = new MenuItemCollection(); + + if (id == Constants.System.Root.ToInvariantString()) + { + //Create User + var createMenuItem = menu.Items.CreateMenuItem(Services.TextService.Localize("actions/create")); + createMenuItem.Icon = "add"; + createMenuItem.NavigateToRoute("users/users/overview?subview=users&create=true"); + menu.Items.Add(createMenuItem); + + //This is the same setting used in the global JS for 'showUserInvite' + if (EmailSender.CanSendRequiredEmail) + { + //Invite User (Action import closest type of action to an invite user) + var inviteMenuItem = menu.Items.CreateMenuItem(Services.TextService.Localize("user/invite")); + inviteMenuItem.Icon = "message-unopened"; + inviteMenuItem.NavigateToRoute("users/users/overview?subview=users&invite=true"); + + menu.Items.Add(inviteMenuItem); + } + + return menu; + } + + //There is no context menu options for editing a specific user + //Also we no longer list each user in the tree & in theory never hit this return menu; } } diff --git a/src/Umbraco.Web/Trees/XsltTreeController.cs b/src/Umbraco.Web/Trees/XsltTreeController.cs index 185ec4a192..9afb2f33dc 100644 --- a/src/Umbraco.Web/Trees/XsltTreeController.cs +++ b/src/Umbraco.Web/Trees/XsltTreeController.cs @@ -1,15 +1,69 @@ using System.Collections.Generic; using System.IO; +using System; +using System.Net.Http.Formatting; using Umbraco.Core; +using umbraco; +using umbraco.BusinessLogic.Actions; using Umbraco.Core.IO; using Umbraco.Web.Composing; +using Umbraco.Core.Services; using Umbraco.Web.Models.Trees; +using Umbraco.Web.Mvc; +using Umbraco.Web.WebApi.Filters; +using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.Trees { + [UmbracoTreeAuthorize(Constants.Trees.Xslt)] [Tree(Constants.Applications.Settings, Constants.Trees.Xslt, "XSLT Files", "icon-folder", "icon-folder", sortOrder: 2)] + [PluginController("UmbracoTrees")] + [CoreTree] public class XsltTreeController : FileSystemTreeController { + protected override void OnRenderFileNode(ref TreeNode treeNode) + { + ////TODO: This is all hacky ... don't have time to convert the tree, views and dialogs over properly so we'll keep using the legacy views + treeNode.AssignLegacyJsCallback("javascript:UmbClientMgr.contentFrame('developer/xslt/editXslt.aspx?file=" + treeNode.Id + "');"); + } + + protected override void OnRenderFolderNode(ref TreeNode treeNode) + { + //TODO: This is all hacky ... don't have time to convert the tree, views and dialogs over properly so we'll keep using the legacy views + treeNode.AssignLegacyJsCallback("javascript:void(0);"); + } + + protected override MenuItemCollection GetMenuForFile(string path, FormDataCollection queryStrings) + { + var menu = new MenuItemCollection(); + + //TODO: This is all hacky ... don't have time to convert the tree, views and dialogs over properly so we'll keep using the legacy dialogs + var menuItem = menu.Items.Add(ActionDelete.Instance, Services.TextService.Localize(string.Format("actions/{0}", ActionDelete.Instance.Alias))); + var legacyConfirmView = LegacyTreeDataConverter.GetLegacyConfirmView(ActionDelete.Instance); + if (legacyConfirmView == false) + throw new InvalidOperationException("Could not resolve the confirmation view for the legacy action " + ActionDelete.Instance.Alias); + menuItem.LaunchDialogView( + legacyConfirmView.Result, + Services.TextService.Localize("general/delete")); + + return menu; + } + + protected override MenuItemCollection GetMenuForRootNode(FormDataCollection queryStrings) + { + var menu = new MenuItemCollection(); + + //set the default to create + menu.DefaultMenuAlias = ActionNew.Instance.Alias; + + // root actions + menu.Items.Add(Services.TextService.Localize(string.Format("actions/{0}", ActionNew.Instance.Alias))) + .ConvertLegacyMenuItem(null, Constants.Trees.Xslt, queryStrings.GetValue("application")); + + menu.Items.Add(ui.Text("actions", ActionRefresh.Instance.Alias), true); + return menu; + } + protected override IFileSystem FileSystem => Current.FileSystems.XsltFileSystem; // fixme inject private static readonly string[] ExtensionsStatic = { "xslt" }; diff --git a/src/Umbraco.Web/UI/JavaScript/JsInitialize.js b/src/Umbraco.Web/UI/JavaScript/JsInitialize.js index 3be8a58983..961fb8e54e 100644 --- a/src/Umbraco.Web/UI/JavaScript/JsInitialize.js +++ b/src/Umbraco.Web/UI/JavaScript/JsInitialize.js @@ -3,6 +3,8 @@ 'lib/angular/1.1.5/angular.min.js', 'lib/underscore/underscore-min.js', + 'lib/moment/moment-with-locales.js', + 'lib/jquery-ui/jquery-ui.min.js', 'lib/jquery-ui-touch-punch/jquery.ui.touch-punch.js', @@ -14,7 +16,7 @@ 'lib/angular-dynamic-locale/tmhDynamicLocale.min.js', 'lib/ng-file-upload/ng-file-upload.min.js', - 'lib/angular-local-storage/angular-local-storage.min.js', + 'lib/angular-local-storage/angular-local-storage.min.js', //"lib/ace-builds/src-min-noconflict/ace.js", diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 88f9430f2b..e3753ed994 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -63,7 +63,7 @@ - + @@ -145,6 +145,7 @@ + @@ -205,11 +206,13 @@ + + @@ -222,13 +225,24 @@ + + + + + + + + + + + @@ -236,6 +250,7 @@ + @@ -365,8 +380,11 @@ - + + + + @@ -649,7 +667,6 @@ - @@ -731,7 +748,7 @@ - + @@ -921,7 +938,7 @@ - + @@ -1045,7 +1062,7 @@ - + diff --git a/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs b/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs index f2675c21ae..c9a10acfa0 100644 --- a/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs +++ b/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs @@ -53,12 +53,11 @@ namespace Umbraco.Web /// protected virtual void ConfigureMiddleware(IAppBuilder app) { - //Ensure owin is configured for Umbraco back office authentication. If you have any front-end OWIN - // cookie configuration, this must be declared after it. + + // Configure OWIN for authentication. + ConfigureUmbracoAuthentication(app); + app - .UseUmbracoBackOfficeCookieAuthentication(Current.RuntimeState, PipelineStage.Authenticate) - .UseUmbracoBackOfficeExternalCookieAuthentication(Current.RuntimeState, PipelineStage.Authenticate) - .UseUmbracoPreviewAuthentication(Current.RuntimeState, PipelineStage.Authorize) .UseSignalR() .FinalizeMiddlewareConfiguration(); } @@ -75,6 +74,20 @@ namespace Umbraco.Web Core.Security.MembershipProviderExtensions.GetUsersMembershipProvider().AsUmbracoMembershipProvider()); } + /// + /// Configure external/OAuth login providers + /// + /// + protected virtual void ConfigureUmbracoAuthentication(IAppBuilder app) + { + // Ensure owin is configured for Umbraco back office authentication. + // Front-end OWIN cookie configuration must be declared after this code. + app + .UseUmbracoBackOfficeCookieAuthentication(Current.RuntimeState, PipelineStage.Authenticate) + .UseUmbracoBackOfficeExternalCookieAuthentication(Current.RuntimeState, PipelineStage.Authenticate) + .UseUmbracoPreviewAuthentication(Current.RuntimeState, PipelineStage.Authorize); + } + public static event EventHandler MiddlewareConfigured; internal static void OnMiddlewareConfigured(OwinMiddlewareConfiguredEventArgs args) diff --git a/src/Umbraco.Web/UmbracoHelper.cs b/src/Umbraco.Web/UmbracoHelper.cs index 9d5f2a12ae..2386e4abef 100644 --- a/src/Umbraco.Web/UmbracoHelper.cs +++ b/src/Umbraco.Web/UmbracoHelper.cs @@ -436,8 +436,13 @@ namespace Umbraco.Web public IPublishedContent Member(object id) { - var asInt = id.TryConvertTo(); - return asInt ? MembershipHelper.GetById(asInt.Result) : MembershipHelper.GetByProviderKey(id); + if (ConvertIdObjectToInt(id, out var intId)) + return Member(intId); + if (ConvertIdObjectToGuid(id, out var guidId)) + return Member(guidId); + if (ConvertIdObjectToUdi(id, out var udiId)) + return Member(udiId); + return null; } public IPublishedContent Member(int id) @@ -699,7 +704,7 @@ namespace Umbraco.Web #region Media - public IPublishedContent TypedMedia(Udi id) + public IPublishedContent Media(Udi id) { var guidUdi = id as GuidUdi; return guidUdi == null ? null : Media(guidUdi.Guid); @@ -839,7 +844,7 @@ namespace Umbraco.Web #region Search /// - /// Searches content + /// Searches content. /// /// /// @@ -851,7 +856,36 @@ namespace Umbraco.Web } /// - /// Searhes content + /// Searches content. + /// + /// + /// + /// + /// + /// + /// + /// + public IEnumerable TypedSearch(int skip, int take, out int totalRecords, string term, bool useWildCards = true, string searchProvider = null) + { + return ContentQuery.Search(skip, take, out totalRecords, term, useWildCards, searchProvider); + } + + /// + /// Searhes content. + /// + /// + /// + /// + /// + /// + /// + public IEnumerable TypedSearch(int skip, int take, out int totalRecords, Examine.SearchCriteria.ISearchCriteria criteria, Examine.Providers.BaseSearchProvider searchProvider = null) + { + return ContentQuery.Search(skip, take, out totalRecords, criteria, searchProvider); + } + + /// + /// Searhes content. /// /// /// diff --git a/src/Umbraco.Web/UriUtility.cs b/src/Umbraco.Web/UriUtility.cs index d5ad7d3854..0a76262f6c 100644 --- a/src/Umbraco.Web/UriUtility.cs +++ b/src/Umbraco.Web/UriUtility.cs @@ -68,7 +68,7 @@ namespace Umbraco.Web if (!GlobalSettings.UseDirectoryUrls) path += ".aspx"; else if (UmbracoConfig.For.UmbracoSettings().RequestHandler.AddTrailingSlash) - path += "/"; + path = path.EnsureEndsWith("/"); } path = ToAbsolute(path); diff --git a/src/Umbraco.Web/WebApi/Binders/MemberBinder.cs b/src/Umbraco.Web/WebApi/Binders/MemberBinder.cs index a1471ab640..2bb3c795f0 100644 --- a/src/Umbraco.Web/WebApi/Binders/MemberBinder.cs +++ b/src/Umbraco.Web/WebApi/Binders/MemberBinder.cs @@ -14,6 +14,7 @@ using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.WebApi.Filters; using System.Linq; +using System.Net.Http; using Umbraco.Core.Models.Membership; using Umbraco.Core.Services.Implement; using Umbraco.Web; @@ -85,17 +86,8 @@ namespace Umbraco.Web.WebApi.Binders if (member == null) { throw new InvalidOperationException("Could not find member with key " + key); - } + } - var standardProps = Constants.Conventions.Member.GetStandardPropertyTypeStubs(); - - //remove all membership properties, these values are set with the membership provider. - var exclude = standardProps.Select(x => x.Value.Alias).ToArray(); - - foreach (var remove in exclude) - { - member.Properties.Remove(remove); - } return member; } @@ -232,6 +224,14 @@ namespace Umbraco.Web.WebApi.Binders return base.ValidatePropertyData(postedItem, actionContext); } + /// + /// This ensures that the internal membership property types are removed from validation before processing the validation + /// since those properties are actually mapped to real properties of the IMember. + /// This also validates any posted data for fields that are sensitive. + /// + /// + /// + /// protected override bool ValidateProperties(ContentItemBasic postedItem, HttpActionContext actionContext) { var propertiesToValidate = postedItem.Properties.ToList(); @@ -242,9 +242,41 @@ namespace Umbraco.Web.WebApi.Binders propertiesToValidate.RemoveAll(property => property.Alias == remove); } - return ValidateProperties(propertiesToValidate.ToArray(), postedItem.PersistedContent.Properties.ToArray(), actionContext); - } + var httpCtx = actionContext.Request.TryGetHttpContext(); + if (httpCtx.Success == false) + { + actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.NotFound, "No http context"); + return false; + } + var umbCtx = httpCtx.Result.GetUmbracoContext(); + //if the user doesn't have access to sensitive values, then we need to validate the incoming properties to check + //if a sensitive value is being submitted. + if (umbCtx.Security.CurrentUser.HasAccessToSensitiveData() == false) + { + var sensitiveProperties = postedItem.PersistedContent.ContentType + .PropertyTypes.Where(x => postedItem.PersistedContent.ContentType.IsSensitiveProperty(x.Alias)) + .ToList(); + + foreach (var sensitiveProperty in sensitiveProperties) + { + var prop = propertiesToValidate.FirstOrDefault(x => x.Alias == sensitiveProperty.Alias); + + if (prop != null) + { + //this should not happen, this means that there was data posted for a sensitive property that + //the user doesn't have access to, which means that someone is trying to hack the values. + + var message = string.Format("property with alias: {0} cannot be posted", prop.Alias); + actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.NotFound, new InvalidOperationException(message)); + return false; + } + } + } + + return ValidateProperties(propertiesToValidate, postedItem.PersistedContent.Properties.ToList(), actionContext); + } + internal bool ValidateUniqueLogin(MemberSave contentItem, MembershipProvider membershipProvider, HttpActionContext actionContext) { if (contentItem == null) throw new ArgumentNullException("contentItem"); diff --git a/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs b/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs index d2781dbd2d..411c393824 100644 --- a/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs @@ -20,6 +20,9 @@ namespace Umbraco.Web.WebApi.Filters /// to what is persisted for the current user and will update the current auth ticket with the correct data if required and output /// a custom response header for the UI to be notified of it. /// + /// + /// This could/should be created as a filter on the BackOfficeCookieAuthenticationProvider just like the SecurityStampValidator does + /// public sealed class CheckIfUserTicketDataIsStaleAttribute : ActionFilterAttribute { public override async Task OnActionExecutingAsync(HttpActionContext actionContext, CancellationToken cancellationToken) @@ -107,12 +110,12 @@ namespace Umbraco.Web.WebApi.Filters if (owinCtx) { var signInManager = owinCtx.Result.GetBackOfficeSignInManager(); - - //ensure the remainder of the request has the correct principal set - actionContext.Request.SetPrincipalForRequest(user); - + var backOfficeIdentityUser = Mapper.Map(user); await signInManager.SignInAsync(backOfficeIdentityUser, isPersistent: true, rememberBrowser: false); + + //ensure the remainder of the request has the correct principal set + actionContext.Request.SetPrincipalForRequest(owinCtx.Result.Request.User); //flag that we've made changes actionContext.Request.Properties[typeof(CheckIfUserTicketDataIsStaleAttribute).Name] = true; diff --git a/src/Umbraco.Web/WebApi/Filters/ContentItemValidationHelper.cs b/src/Umbraco.Web/WebApi/Filters/ContentItemValidationHelper.cs index 02396fc92f..fcf1413c32 100644 --- a/src/Umbraco.Web/WebApi/Filters/ContentItemValidationHelper.cs +++ b/src/Umbraco.Web/WebApi/Filters/ContentItemValidationHelper.cs @@ -72,7 +72,7 @@ namespace Umbraco.Web.WebApi.Filters /// protected virtual bool ValidateProperties(ContentItemBasic postedItem, HttpActionContext actionContext) { - return ValidateProperties(postedItem.Properties.ToArray(), postedItem.PersistedContent.Properties.ToArray(), actionContext); + return ValidateProperties(postedItem.Properties.ToList(), postedItem.PersistedContent.Properties.ToList(), actionContext); } /// @@ -82,7 +82,7 @@ namespace Umbraco.Web.WebApi.Filters /// /// /// - protected bool ValidateProperties(ContentPropertyBasic[] postedProperties , Property[] persistedProperties, HttpActionContext actionContext) + protected bool ValidateProperties(List postedProperties , List persistedProperties, HttpActionContext actionContext) { foreach (var p in postedProperties) { @@ -124,8 +124,12 @@ namespace Umbraco.Web.WebApi.Filters continue; } - // get the posted value - var postedValue = postedItem.Properties.Single(x => x.Alias == p.Alias).Value; + //get the posted value for this property, this may be null in cases where the property was marked as readonly which means + //the angular app will not post that value. + var postedProp = postedItem.Properties.FirstOrDefault(x => x.Alias == p.Alias); + if (postedProp == null) continue; + + var postedValue = postedProp.Value; // validate var valueEditor = editor.GetValueEditor(p.DataType.Configuration); diff --git a/src/Umbraco.Web/WebApi/Filters/FeatureAuthorizeAttribute.cs b/src/Umbraco.Web/WebApi/Filters/FeatureAuthorizeAttribute.cs new file mode 100644 index 0000000000..dd1954663f --- /dev/null +++ b/src/Umbraco.Web/WebApi/Filters/FeatureAuthorizeAttribute.cs @@ -0,0 +1,23 @@ +using System.Web.Http; +using System.Web.Http.Controllers; +using Umbraco.Web.Features; + +namespace Umbraco.Web.WebApi.Filters +{ + /// + /// Ensures that the controller is an authorized feature. + /// + /// Else returns unauthorized. + public sealed class FeatureAuthorizeAttribute : AuthorizeAttribute + { + protected override bool IsAuthorized(HttpActionContext actionContext) + { + //if no features resolver has been set then return true, this will occur in unit tests and we don't want users to have to set a resolver + //just so their unit tests work. + if (FeaturesResolver.HasCurrent == false) return true; + + var controllerType = actionContext.ControllerContext.ControllerDescriptor.ControllerType; + return FeaturesResolver.Current.Features.IsEnabled(controllerType); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/WebApi/Filters/FilterAllowedOutgoingContentAttribute.cs b/src/Umbraco.Web/WebApi/Filters/FilterAllowedOutgoingContentAttribute.cs index 59dd55304e..74001040e3 100644 --- a/src/Umbraco.Web/WebApi/Filters/FilterAllowedOutgoingContentAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/FilterAllowedOutgoingContentAttribute.cs @@ -97,30 +97,19 @@ namespace Umbraco.Web.WebApi.Filters ids.Add(((dynamic)items[i]).Id); } //get all the permissions for these nodes in one call - var permissions = _userService.GetPermissions(user, ids.ToArray()).ToArray(); + var permissions = _userService.GetPermissions(user, ids.ToArray()); var toRemove = new List(); foreach (dynamic item in items) - { - var nodePermission = permissions.Where(x => x.EntityId == Convert.ToInt32(item.Id)).ToArray(); - //if there are no permissions for this id then we need to check what the user's default - // permissions are. - if (nodePermission.Length == 0) + { + //get the combined permission set across all user groups for this node + //we're in the world of dynamics here so we need to cast + var nodePermission = ((IEnumerable)permissions.GetAllPermissions(item.Id)).ToArray(); + + //if the permission being checked doesn't exist then remove the item + if (nodePermission.Contains(_permissionToCheck.ToString(CultureInfo.InvariantCulture)) == false) { - //var defaultP = user.DefaultPermissions - toRemove.Add(item); - } - else - { - foreach (var n in nodePermission) - { - //if the permission being checked doesn't exist then remove the item - if (n.AssignedPermissions.Contains(_permissionToCheck.ToString(CultureInfo.InvariantCulture)) == false) - { - toRemove.Add(item); - } - } - } + } } foreach (var item in toRemove) { diff --git a/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs b/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs index 99b3e0825e..cb6e0a73f6 100644 --- a/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs +++ b/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs @@ -9,6 +9,7 @@ using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Core.Services; using Umbraco.Web.Security; +using Umbraco.Web.WebApi.Filters; namespace Umbraco.Web.WebApi { @@ -16,6 +17,7 @@ namespace Umbraco.Web.WebApi /// Provides a base class for Umbraco API controllers. /// /// These controllers are NOT auto-routed. + [FeatureAuthorize] public abstract class UmbracoApiControllerBase : ApiController { private UmbracoHelper _umbracoHelper; diff --git a/src/Umbraco.Web/WebServices/ExamineManagementApiController.cs b/src/Umbraco.Web/WebServices/ExamineManagementApiController.cs index ab07429223..69e88f2c7b 100644 --- a/src/Umbraco.Web/WebServices/ExamineManagementApiController.cs +++ b/src/Umbraco.Web/WebServices/ExamineManagementApiController.cs @@ -8,7 +8,9 @@ using Examine; using Examine.LuceneEngine; using Examine.LuceneEngine.Providers; using Examine.Providers; +using Lucene.Net.Index; using Lucene.Net.Search; +using Lucene.Net.Store; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Logging; @@ -194,21 +196,42 @@ namespace Umbraco.Web.WebServices try { indexer.RebuildIndex(); - } + } + // fixme - Examine issues + // cannot enable that piece of code from v7 as Index.Write.Unlock does not seem to exist anymore? + //catch (LockObtainFailedException) + //{ + // //this will occur if the index is locked (which it should defo not be!) so in this case we'll forcibly unlock it and try again + + // try + // { + // IndexWriter.Unlock(indexer.GetLuceneDirectory()); + // indexer.RebuildIndex(); + // } + // catch (Exception e) + // { + // return HandleException(e, indexer); + // } + //} catch (Exception ex) { - //ensure it's not listening - indexer.IndexOperationComplete -= Indexer_IndexOperationComplete; - Logger.Error("An error occurred rebuilding index", ex); - var response = Request.CreateResponse(HttpStatusCode.Conflict); - response.Content = new StringContent(string.Format("The index could not be rebuilt at this time, most likely there is another thread currently writing to the index. Error: {0}", ex)); - response.ReasonPhrase = "Could Not Rebuild"; - return response; + return HandleException(ex, indexer); } } return msg; } + private HttpResponseMessage HandleException(Exception ex, LuceneIndexer indexer) + { + //ensure it's not listening + indexer.IndexOperationComplete -= Indexer_IndexOperationComplete; + Logger.Error("An error occurred rebuilding index", ex); + var response = Request.CreateResponse(HttpStatusCode.Conflict); + response.Content = new StringContent(string.Format("The index could not be rebuilt at this time, most likely there is another thread currently writing to the index. Error: {0}", ex)); + response.ReasonPhrase = "Could Not Rebuild"; + return response; + } + //static listener so it's not GC'd private static void Indexer_IndexOperationComplete(object sender, EventArgs e) { diff --git a/src/Umbraco.Web/_Legacy/UI/LegacyDialogHandler.cs b/src/Umbraco.Web/_Legacy/UI/LegacyDialogHandler.cs index 07ed6c03d7..cfa93dc0f6 100644 --- a/src/Umbraco.Web/_Legacy/UI/LegacyDialogHandler.cs +++ b/src/Umbraco.Web/_Legacy/UI/LegacyDialogHandler.cs @@ -120,8 +120,11 @@ namespace Umbraco.Web._Legacy.UI internal static bool UserHasCreateAccess(HttpContextBase httpContext, IUser umbracoUser, string nodeType) { var task = GetTaskForOperation(httpContext, umbracoUser, Operation.Create, nodeType); + + //if no task was found it will use the default task and we cannot validate the application assigned so return true if (task == null) - throw new InvalidOperationException($"Could not task for operation {Operation.Create} for node type {nodeType}."); + return true; + return task is LegacyDialogTask ltask ? ltask.ValidateUserForApplication() : true; } @@ -141,8 +144,11 @@ namespace Umbraco.Web._Legacy.UI internal static bool UserHasDeleteAccess(HttpContextBase httpContext, User umbracoUser, string nodeType) { var task = GetTaskForOperation(httpContext, umbracoUser, Operation.Delete, nodeType); + + //if no task was found it will use the default task and we cannot validate the application assigned so return true if (task == null) - throw new InvalidOperationException($"Could not task for operation {Operation.Delete} for node type {nodeType}"); + return true; + return task is LegacyDialogTask ltask ? ltask.ValidateUserForApplication() : true; } From d40a835701f7e18f687bf98e5b9335173697c708 Mon Sep 17 00:00:00 2001 From: Stephan Date: Tue, 27 Mar 2018 10:51:41 +0200 Subject: [PATCH 09/15] Port v7@2aa0dfb2c5 - WIP --- src/Umbraco.Core/Scoping/IScopeContext.cs | 8 + .../Editors/EditorModelEventManager.cs | 64 ++--- .../PublishedContentCache.cs | 5 + .../XmlPublishedCache/PublishedMediaCache.cs | 38 ++- .../XmlPublishedCache/SafeXmlReaderWriter.cs | 15 +- .../XmlPublishedCache/XmlStore.cs | 5 +- src/Umbraco.Web/Search/ExamineComponent.cs | 239 ++++++++++++++---- src/Umbraco.Web/Search/UmbracoTreeSearcher.cs | 4 + .../Identity/BackOfficeCookieManager.cs | 1 + .../Identity/ExternalSignInAutoLinkOptions.cs | 6 + ...ForceRenewalCookieAuthenticationHandler.cs | 2 +- .../Identity/GetUserSecondsMiddleWare.cs | 16 ++ src/Umbraco.Web/Security/WebAuthExtensions.cs | 22 +- src/Umbraco.Web/Security/WebSecurity.cs | 30 +-- src/Umbraco.Web/Umbraco.Web.csproj | 9 + .../developer/Macros/editMacro.aspx.cs | 4 +- .../developer/Packages/editPackage.aspx.cs | 7 + .../umbraco/developer/Xslt/editXslt.aspx.cs | 2 +- .../dialogs/importDocumenttype.aspx.cs | 2 + .../umbraco/dialogs/notifications.aspx.cs | 2 +- .../umbraco/dialogs/rollBack.aspx.cs | 35 +-- .../umbraco/dialogs/viewAuditTrail.aspx | 1 + .../umbraco/translation/default.aspx.cs | 1 - .../umbraco/translation/details.aspx.cs | 1 - .../umbraco/webservices/nodeSorter.asmx.cs | 15 +- 25 files changed, 363 insertions(+), 171 deletions(-) diff --git a/src/Umbraco.Core/Scoping/IScopeContext.cs b/src/Umbraco.Core/Scoping/IScopeContext.cs index f4fb652bc7..093ebef4f7 100644 --- a/src/Umbraco.Core/Scoping/IScopeContext.cs +++ b/src/Umbraco.Core/Scoping/IScopeContext.cs @@ -38,5 +38,13 @@ namespace Umbraco.Core.Scoping /// The action boolean parameter indicates whether the scope completed or not. /// T Enlist(string key, Func creator, Action action = null, int priority = 100); + + /// + /// Gets an enlisted object. + /// + /// The type of the object. + /// The object unique identifier. + /// The enlisted object, if any, else the default value. + T GetEnlisted(string key); } } diff --git a/src/Umbraco.Web/Editors/EditorModelEventManager.cs b/src/Umbraco.Web/Editors/EditorModelEventManager.cs index 0dea726142..e2a248cb88 100644 --- a/src/Umbraco.Web/Editors/EditorModelEventManager.cs +++ b/src/Umbraco.Web/Editors/EditorModelEventManager.cs @@ -1,39 +1,9 @@ -using System; -using System.Web.Http.Filters; +using System.Web.Http.Filters; using Umbraco.Core.Events; using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Editors { - public class EditorModelEventArgs : EventArgs - { - public EditorModelEventArgs(object model, UmbracoContext umbracoContext) - { - Model = model; - UmbracoContext = umbracoContext; - } - - public object Model { get; private set; } - public UmbracoContext UmbracoContext { get; private set; } - } - - public sealed class EditorModelEventArgs : EditorModelEventArgs - { - public EditorModelEventArgs(EditorModelEventArgs baseArgs) - : base(baseArgs.Model, baseArgs.UmbracoContext) - { - Model = (T)baseArgs.Model; - } - - public EditorModelEventArgs(T model, UmbracoContext umbracoContext) - : base(model, umbracoContext) - { - Model = model; - } - - public new T Model { get; private set; } - } - /// /// Used to emit events for editor models in the back office /// @@ -42,23 +12,30 @@ namespace Umbraco.Web.Editors public static event TypedEventHandler> SendingContentModel; public static event TypedEventHandler> SendingMediaModel; public static event TypedEventHandler> SendingMemberModel; + public static event TypedEventHandler> SendingUserModel; + + private static void OnSendingUserModel(HttpActionExecutedContext sender, EditorModelEventArgs e) + { + var handler = SendingUserModel; + handler?.Invoke(sender, e); + } private static void OnSendingContentModel(HttpActionExecutedContext sender, EditorModelEventArgs e) { var handler = SendingContentModel; - if (handler != null) handler(sender, e); + handler?.Invoke(sender, e); } private static void OnSendingMediaModel(HttpActionExecutedContext sender, EditorModelEventArgs e) { var handler = SendingMediaModel; - if (handler != null) handler(sender, e); + handler?.Invoke(sender, e); } private static void OnSendingMemberModel(HttpActionExecutedContext sender, EditorModelEventArgs e) { var handler = SendingMemberModel; - if (handler != null) handler(sender, e); + handler?.Invoke(sender, e); } /// @@ -68,24 +45,17 @@ namespace Umbraco.Web.Editors /// internal static void EmitEvent(HttpActionExecutedContext sender, EditorModelEventArgs e) { - var contentItemDisplay = e.Model as ContentItemDisplay; - if (contentItemDisplay != null) - { + if (e.Model is ContentItemDisplay) OnSendingContentModel(sender, new EditorModelEventArgs(e)); - } - var mediaItemDisplay = e.Model as MediaItemDisplay; - if (mediaItemDisplay != null) - { + if (e.Model is MediaItemDisplay) OnSendingMediaModel(sender, new EditorModelEventArgs(e)); - } - var memberItemDisplay = e.Model as MemberDisplay; - if (memberItemDisplay != null) - { + if (e.Model is MemberDisplay) OnSendingMemberModel(sender, new EditorModelEventArgs(e)); - } - } + if (e.Model is UserDisplay) + OnSendingUserModel(sender, new EditorModelEventArgs(e)); + } } } diff --git a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedContentCache.cs b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedContentCache.cs index f3829c35c0..58d856ad8d 100644 --- a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedContentCache.cs +++ b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedContentCache.cs @@ -486,7 +486,12 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache // not trying to be thread-safe here, that's not the point if (preview == false) + { + // if there's a current enlisted reader/writer, use its xml + var tempXml = _xmlStore.TempXml; + if (tempXml != null) return tempXml; return _xml; + } // Xml cache does not support retrieving preview content when not previewing if (_previewContent == null) diff --git a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedMediaCache.cs b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedMediaCache.cs index 690ae7e5b7..df4b7990ad 100644 --- a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedMediaCache.cs +++ b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/PublishedMediaCache.cs @@ -108,7 +108,43 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache public override IEnumerable GetAtRoot(bool preview) { - //TODO: We should be able to look these ids first in Examine! + var searchProvider = GetSearchProviderSafe(); + + if (searchProvider != null) + { + try + { + // first check in Examine for the cache values + // +(+parentID:-1) +__IndexType:media + + var criteria = searchProvider.CreateSearchCriteria("media"); + var filter = criteria.ParentId(-1).Not().Field(UmbracoContentIndexer.IndexPathFieldName, "-1,-21,".MultipleCharacterWildcard()); + + var result = searchProvider.Search(filter.Compile()); + if (result != null) + return result.Select(x => CreateFromCacheValues(ConvertFromSearchResult(x))); + } + catch (Exception ex) + { + if (ex is FileNotFoundException) + { + //Currently examine is throwing FileNotFound exceptions when we have a loadbalanced filestore and a node is published in umbraco + //See this thread: http://examine.cdodeplex.com/discussions/264341 + //Catch the exception here for the time being, and just fallback to GetMedia + //TODO: Need to fix examine in LB scenarios! + Current.Logger.Error("Could not load data from Examine index for media", ex); + } + else if (ex is AlreadyClosedException) + { + //If the app domain is shutting down and the site is under heavy load the index reader will be closed and it really cannot + //be re-opened since the app domain is shutting down. In this case we have no option but to try to load the data from the db. + Current.Logger.Error("Could not load data from Examine index for media, the app domain is most likely in a shutdown state", ex); + } + else throw; + } + } + + //something went wrong, fetch from the db var rootMedia = _mediaService.GetRootMedia(); return rootMedia.Select(m => GetUmbracoMedia(m.Id)); diff --git a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/SafeXmlReaderWriter.cs b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/SafeXmlReaderWriter.cs index a84d443397..7f6a6fd704 100644 --- a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/SafeXmlReaderWriter.cs +++ b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/SafeXmlReaderWriter.cs @@ -17,6 +17,11 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache private bool _using; private bool _registerXmlChange; + // the default enlist priority is 100 + // enlist with a lower priority to ensure that anything "default" has a clean xml + private const int EnlistPriority = 60; + private const string EnlistKey = "safeXmlReaderWriter"; + private SafeXmlReaderWriter(IDisposable releaser, XmlDocument xml, Action refresh, Action apply, bool isWriter, bool scoped) { _releaser = releaser; @@ -29,6 +34,12 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache _xml = IsWriter ? Clone(xml) : xml; } + public static SafeXmlReaderWriter Get(IScopeProvider scopeProvider) + { + var scopeContext = scopeProvider.Context; + return scopeContext?.GetEnlisted(EnlistKey); + } + public static SafeXmlReaderWriter Get(IScopeProvider scopeProvider, AsyncLock xmlLock, XmlDocument xml, Action refresh, Action apply, bool writer) { var scopeContext = scopeProvider.Context; @@ -42,7 +53,7 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache } // get or create an enlisted reader/writer - var rw = scopeContext.Enlist("safeXmlReaderWriter", + var rw = scopeContext.Enlist(EnlistKey, () => // creator { // obtain exclusive access to xml and create reader/writer @@ -52,7 +63,7 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache (completed, item) => // action { item.DisposeForReal(completed); - }); + }, EnlistPriority); // ensure it's not already in-use - should never happen, just being super safe if (rw._using) diff --git a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlStore.cs b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlStore.cs index 8c56ffd87f..a0e7f6370d 100644 --- a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlStore.cs +++ b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlStore.cs @@ -38,7 +38,7 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache /// then passed to all instances that are created (one per request). /// This class should *not* be public. /// - class XmlStore : IDisposable + internal class XmlStore : IDisposable { private readonly IDocumentRepository _documentRepository; private readonly IMediaRepository _mediaRepository; @@ -329,6 +329,9 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache } } + // Gets the temp. Xml managed by SafeXmlReaderWrite, if any + public XmlDocument TempXml => SafeXmlReaderWriter.Get(_scopeProvider)?.Xml; + // assumes xml lock private void SetXmlLocked(XmlDocument xml, bool registerXmlChange) { diff --git a/src/Umbraco.Web/Search/ExamineComponent.cs b/src/Umbraco.Web/Search/ExamineComponent.cs index 411f77fb3f..50940ecf4c 100644 --- a/src/Umbraco.Web/Search/ExamineComponent.cs +++ b/src/Umbraco.Web/Search/ExamineComponent.cs @@ -14,6 +14,7 @@ using Umbraco.Core.Components; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Scoping; using Umbraco.Core.Services; using Umbraco.Core.Services.Changes; using Umbraco.Core.Services.Implement; @@ -31,8 +32,17 @@ namespace Umbraco.Web.Search [RuntimeLevel(MinLevel = RuntimeLevel.Run)] public sealed class ExamineComponent : UmbracoComponentBase, IUmbracoCoreComponent { - public void Initialize(IRuntimeState runtime, PropertyEditorCollection propertyEditors, IExamineIndexCollectionAccessor indexCollection, ILogger logger) + private IScopeProvider _scopeProvider; + + // the default enlist priority is 100 + // enlist with a lower priority to ensure that anything "default" runs after us + // but greater that SafeXmlReaderWriter priority which is 60 + private const int EnlistPriority = 80; + + public void Initialize(IRuntimeState runtime, PropertyEditorCollection propertyEditors, IExamineIndexCollectionAccessor indexCollection, IScopeProvider scopeProvider, ILogger logger) { + _scopeProvider = scopeProvider; + logger.Info("Starting initialize async background thread."); // make it async in order not to slow down the boot @@ -119,7 +129,7 @@ namespace Umbraco.Web.Search i.DocumentWriting += grid.DocumentWriting; } - static void MemberCacheRefresherUpdated(MemberCacheRefresher sender, CacheRefresherEventArgs args) + void MemberCacheRefresherUpdated(MemberCacheRefresher sender, CacheRefresherEventArgs args) { if (Suspendable.ExamineEvents.CanIndex == false) return; @@ -164,7 +174,7 @@ namespace Umbraco.Web.Search } } - static void MediaCacheRefresherUpdated(MediaCacheRefresher sender, CacheRefresherEventArgs args) + void MediaCacheRefresherUpdated(MediaCacheRefresher sender, CacheRefresherEventArgs args) { if (Suspendable.ExamineEvents.CanIndex == false) return; @@ -213,7 +223,7 @@ namespace Umbraco.Web.Search } } - static void ContentCacheRefresherUpdated(ContentCacheRefresher sender, CacheRefresherEventArgs args) + void ContentCacheRefresherUpdated(ContentCacheRefresher sender, CacheRefresherEventArgs args) { if (Suspendable.ExamineEvents.CanIndex == false) return; @@ -289,7 +299,7 @@ namespace Umbraco.Web.Search } } - private static void ReIndexForContent(IContent content, IContent published) + private void ReIndexForContent(IContent content, IContent published) { if (published != null && content.VersionId == published.VersionId) { @@ -311,42 +321,30 @@ namespace Umbraco.Web.Search } } - private static void ReIndexForContent(IContent sender, bool? supportUnpublished = null) + private void ReIndexForContent(IContent sender, bool? supportUnpublished = null) { - var xml = sender.ToXml(); - //add an icon attribute to get indexed - xml.Add(new XAttribute("icon", sender.ContentType.Icon)); - - ExamineManager.Instance.ReIndexNode( - xml, IndexTypes.Content, - ExamineManager.Instance.IndexProviderCollection.OfType() - // only for the specified indexers - .Where(x => supportUnpublished.HasValue == false || supportUnpublished.Value == x.SupportUnpublishedContent) - .Where(x => x.EnableDefaultEventHandler)); + var actions = DeferedActions.Get(_scopeProvider); + if (actions != null) + actions.Add(new DeferedReIndexForContent(sender, supportUnpublished)); + else + DeferedReIndexForContent.Execute(sender, supportUnpublished); } - private static void ReIndexForMember(IMember member) + private void ReIndexForMember(IMember member) { - ExamineManager.Instance.ReIndexNode( - member.ToXml(), IndexTypes.Member, - ExamineManager.Instance.IndexProviderCollection.OfType() - //ensure that only the providers are flagged to listen execute - .Where(x => x.EnableDefaultEventHandler)); - } + var actions = DeferedActions.Get(_scopeProvider); + if (actions != null) + actions.Add(new DeferedReIndexForMember(member)); + else + DeferedReIndexForMember.Execute(member); } - private static void ReIndexForMedia(IMedia sender, bool isMediaPublished) + private void ReIndexForMedia(IMedia sender, bool isMediaPublished) { - var xml = sender.ToXml(); - //add an icon attribute to get indexed - xml.Add(new XAttribute("icon", sender.ContentType.Icon)); - - ExamineManager.Instance.ReIndexNode( - xml, IndexTypes.Media, - ExamineManager.Instance.IndexProviderCollection.OfType() - // index this item for all indexers if the media is not trashed, otherwise if the item is trashed - // then only index this for indexers supporting unpublished media - .Where(x => isMediaPublished || (x.SupportUnpublishedContent)) - .Where(x => x.EnableDefaultEventHandler)); + var actions = DeferedActions.Get(_scopeProvider); + if (actions != null) + actions.Add(new DeferedReIndexForMedia(sender, isMediaPublished)); + else + DeferedReIndexForMedia.Execute(sender, isMediaPublished); } /// @@ -357,15 +355,13 @@ namespace Umbraco.Web.Search /// If true, indicates that we will only delete this item from indexes that don't support unpublished content. /// If false it will delete this from all indexes regardless. /// - private static void DeleteIndexForEntity(int entityId, bool keepIfUnpublished) + private void DeleteIndexForEntity(int entityId, bool keepIfUnpublished) { - ExamineManager.Instance.DeleteFromIndex( - entityId.ToString(CultureInfo.InvariantCulture), - ExamineManager.Instance.IndexProviderCollection.OfType() - // if keepIfUnpublished == true then only delete this item from indexes not supporting unpublished content, - // otherwise if keepIfUnpublished == false then remove from all indexes - .Where(x => keepIfUnpublished == false || (x is UmbracoContentIndexer && ((UmbracoContentIndexer)x).SupportUnpublishedContent == false)) - .Where(x => x.EnableDefaultEventHandler)); + var actions = DeferedActions.Get(_scopeProvider); + if (actions != null) + actions.Add(new DeferedDeleteIndex(entityId, keepIfUnpublished)); + else + DeferedDeleteIndex.Execute(entityId, keepIfUnpublished); } /// @@ -390,5 +386,162 @@ namespace Umbraco.Web.Search )); } } + + private class DeferedActions + { + private readonly List _actions = new List(); + + public static DeferedActions Get(IScopeProvider scopeProvider) + { + var scopeContext = scopeProvider.Context; + if (scopeContext == null) return null; + + return scopeContext.Enlist("examineEvents", + () => new DeferedActions(), // creator + (completed, actions) => // action + { + if (completed) actions.Execute(); + }, EnlistPriority); + } + + public void Add(DeferedAction action) + { + _actions.Add(action); + } + + private void Execute() + { + foreach (var action in _actions) + action.Execute(); + } + } + + private abstract class DeferedAction + { + public virtual void Execute() + { } + } + + private class DeferedReIndexForContent : DeferedAction + { + private readonly IContent _content; + private readonly bool? _supportUnpublished; + + public DeferedReIndexForContent(IContent content, bool? supportUnpublished) + { + _content = content; + _supportUnpublished = supportUnpublished; + } + + public override void Execute() + { + Execute(_content, _supportUnpublished); + } + + public static void Execute(IContent content, bool? supportUnpublished) + { + var xml = content.ToXml(); + //add an icon attribute to get indexed + xml.Add(new XAttribute("icon", content.ContentType.Icon)); + + ExamineManager.Instance.ReIndexNode( + xml, IndexTypes.Content, + ExamineManager.Instance.IndexProviderCollection.OfType() + + //Index this item for all indexers if the content is published, otherwise if the item is not published + // then only index this for indexers supporting unpublished content + + .Where(x => supportUnpublished.HasValue == false || supportUnpublished.Value == x.SupportUnpublishedContent) + .Where(x => x.EnableDefaultEventHandler)); + } + } + + private class DeferedReIndexForMedia : DeferedAction + { + private readonly IMedia _media; + private readonly bool _isPublished; + + public DeferedReIndexForMedia(IMedia media, bool isPublished) + { + _media = media; + _isPublished = isPublished; + } + + public override void Execute() + { + Execute(_media, _isPublished); + } + + public static void Execute(IMedia media, bool isPublished) + { + var xml = media.ToXml(); + //add an icon attribute to get indexed + xml.Add(new XAttribute("icon", media.ContentType.Icon)); + + ExamineManager.Instance.ReIndexNode( + xml, IndexTypes.Media, + ExamineManager.Instance.IndexProviderCollection.OfType() + + //Index this item for all indexers if the media is not trashed, otherwise if the item is trashed + // then only index this for indexers supporting unpublished media + + .Where(x => isPublished || (x.SupportUnpublishedContent)) + .Where(x => x.EnableDefaultEventHandler)); + } + } + + private class DeferedReIndexForMember : DeferedAction + { + private readonly IMember _member; + + public DeferedReIndexForMember(IMember member) + { + _member = member; + } + + public override void Execute() + { + Execute(_member); + } + + public static void Execute(IMember member) + { + ExamineManager.Instance.ReIndexNode( + member.ToXml(), IndexTypes.Member, + ExamineManager.Instance.IndexProviderCollection.OfType() + //ensure that only the providers are flagged to listen execute + .Where(x => x.EnableDefaultEventHandler)); + } + } + + private class DeferedDeleteIndex : DeferedAction + { + private readonly int _id; + private readonly bool _keepIfUnpublished; + + public DeferedDeleteIndex(int id, bool keepIfUnpublished) + { + _id = id; + _keepIfUnpublished = keepIfUnpublished; + } + + public override void Execute() + { + Execute(_id, _keepIfUnpublished); + } + + public static void Execute(int id, bool keepIfUnpublished) + { + ExamineManager.Instance.DeleteFromIndex( + id.ToString(CultureInfo.InvariantCulture), + ExamineManager.Instance.IndexProviderCollection.OfType() + + //if keepIfUnpublished == true then only delete this item from indexes not supporting unpublished content, + // otherwise if keepIfUnpublished == false then remove from all indexes + + .Where(x => keepIfUnpublished == false || x.SupportUnpublishedContent == false) + .Where(x => x.EnableDefaultEventHandler)); + } + } } } diff --git a/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs b/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs index 917df00c4c..eb565b127e 100644 --- a/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs +++ b/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs @@ -208,6 +208,10 @@ namespace Umbraco.Web.Search if (sb == null) throw new ArgumentNullException("sb"); if (entityService == null) throw new ArgumentNullException("entityService"); + Udi udi; + Udi.TryParse(searchFrom, true, out udi); + searchFrom = udi == null ? searchFrom : entityService.GetIdForUdi(udi).Result.ToString(); + int searchFromId; var entityPath = int.TryParse(searchFrom, out searchFromId) && searchFromId > 0 ? entityService.GetAllPaths(objectType, searchFromId).FirstOrDefault() diff --git a/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs b/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs index c5ad3d121c..98493db1c7 100644 --- a/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs +++ b/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs @@ -6,6 +6,7 @@ using Microsoft.Owin; using Microsoft.Owin.Infrastructure; using Umbraco.Core; using Umbraco.Core.Configuration; +using Umbraco.Core.IO; namespace Umbraco.Web.Security.Identity { diff --git a/src/Umbraco.Web/Security/Identity/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web/Security/Identity/ExternalSignInAutoLinkOptions.cs index a808f7eb62..43738aec17 100644 --- a/src/Umbraco.Web/Security/Identity/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web/Security/Identity/ExternalSignInAutoLinkOptions.cs @@ -52,6 +52,12 @@ namespace Umbraco.Web.Security.Identity /// public Action OnAutoLinking { get; set; } + /// + /// A callback executed during every time a user authenticates using an external login. + /// returns a boolean indicating if sign in should continue or not. + /// + public Func OnExternalLogin { get; set; } + [EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("Use the overload specifying user groups instead")] public string GetDefaultUserType(UmbracoContext umbracoContext, ExternalLoginInfo loginInfo) diff --git a/src/Umbraco.Web/Security/Identity/ForceRenewalCookieAuthenticationHandler.cs b/src/Umbraco.Web/Security/Identity/ForceRenewalCookieAuthenticationHandler.cs index 86b1a0522c..f3c7013701 100644 --- a/src/Umbraco.Web/Security/Identity/ForceRenewalCookieAuthenticationHandler.cs +++ b/src/Umbraco.Web/Security/Identity/ForceRenewalCookieAuthenticationHandler.cs @@ -1,7 +1,7 @@ using System; using Umbraco.Core; using System.Threading.Tasks; -using Microsoft.Owin; +using Umbraco.Core.Security; using Microsoft.Owin.Security; using Microsoft.Owin.Security.Cookies; using Microsoft.Owin.Security.Infrastructure; diff --git a/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs b/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs index 68a356fa82..0efb0b66a6 100644 --- a/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs +++ b/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs @@ -2,12 +2,15 @@ using System.Diagnostics; using System.Globalization; using System.Threading.Tasks; +using System.Web; +using System.Web.Security; using Microsoft.Owin; using Microsoft.Owin.Logging; using Microsoft.Owin.Security.Cookies; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Security; namespace Umbraco.Web.Security.Identity { @@ -82,6 +85,9 @@ namespace Umbraco.Web.Security.Identity //if it's time to renew, then do it if (timeRemaining < timeElapsed) { + //TODO: This would probably be simpler just to do: context.OwinContext.Authentication.SignIn(context.Properties, identity); + // this will invoke the default Cookie middleware to basically perform this logic for us. + ticket.Properties.IssuedUtc = currentUtc; var timeSpan = expiresUtc.Value.Subtract(issuedUtc.Value); ticket.Properties.ExpiresUtc = currentUtc.Add(timeSpan); @@ -99,6 +105,9 @@ namespace Umbraco.Web.Security.Identity remainingSeconds = (ticket.Properties.ExpiresUtc.Value - currentUtc).TotalSeconds; } } + + //We also need to re-validate the user's session if we are relying on this ping to keep their session alive + await SessionIdValidator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context, _authOptions.CookieManager, _authOptions.SystemClock, issuedUtc, ticket.Identity); } else if (remainingSeconds <= 30) { @@ -114,6 +123,13 @@ namespace Umbraco.Web.Security.Identity return; } } + + //Hack! we need to suppress the stupid forms authentcation module but we can only do that by using non owin stuff + if (HttpContext.Current != null && HttpContext.Current.Response != null) + { + HttpContext.Current.Response.SuppressFormsAuthenticationRedirect = true; + } + response.StatusCode = 401; } else if (Next != null) diff --git a/src/Umbraco.Web/Security/WebAuthExtensions.cs b/src/Umbraco.Web/Security/WebAuthExtensions.cs index 138dd36b67..d38345e48c 100644 --- a/src/Umbraco.Web/Security/WebAuthExtensions.cs +++ b/src/Umbraco.Web/Security/WebAuthExtensions.cs @@ -14,18 +14,13 @@ namespace Umbraco.Web.Security internal static class WebAuthExtensions { /// - /// This will set a an authenticated IPrincipal to the current request given the IUser object + /// This will set a an authenticated IPrincipal to the current request for webforms & webapi /// /// - /// + /// /// - internal static IPrincipal SetPrincipalForRequest(this HttpRequestMessage request, IUser user) + internal static IPrincipal SetPrincipalForRequest(this HttpRequestMessage request, IPrincipal principal) { - var principal = new ClaimsPrincipal( - new UmbracoBackOfficeIdentity( - new ClaimsIdentity(), - Mapper.Map(user))); - //It is actually not good enough to set this on the current app Context and the thread, it also needs // to be set explicitly on the HttpContext.Current !! This is a strange web api thing that is actually // an underlying fault of asp.net not propogating the User correctly. @@ -50,15 +45,10 @@ namespace Umbraco.Web.Security /// This will set a an authenticated IPrincipal to the current request given the IUser object /// /// - /// + /// /// - internal static IPrincipal SetPrincipalForRequest(this HttpContextBase httpContext, UserData userData) - { - var principal = new ClaimsPrincipal( - new UmbracoBackOfficeIdentity( - new ClaimsIdentity(), - userData)); - + internal static IPrincipal SetPrincipalForRequest(this HttpContextBase httpContext, IPrincipal principal) + { //It is actually not good enough to set this on the current app Context and the thread, it also needs // to be set explicitly on the HttpContext.Current !! This is a strange web api thing that is actually // an underlying fault of asp.net not propogating the User correctly. diff --git a/src/Umbraco.Web/Security/WebSecurity.cs b/src/Umbraco.Web/Security/WebSecurity.cs index d8d001bab9..547b2d3595 100644 --- a/src/Umbraco.Web/Security/WebSecurity.cs +++ b/src/Umbraco.Web/Security/WebSecurity.cs @@ -23,7 +23,7 @@ namespace Umbraco.Web.Security /// /// A utility class used for dealing with USER security in Umbraco /// - public class WebSecurity : DisposableObject + public class WebSecurity : DisposableObjectSlim { private HttpContextBase _httpContext; private readonly IUserService _userService; @@ -112,34 +112,14 @@ namespace Umbraco.Web.Security owinCtx.Authentication.SignOut(Constants.Security.BackOfficeExternalAuthenticationType); var user = UserManager.FindByIdAsync(userId).Result; - var userData = Mapper.Map(user); - _httpContext.SetPrincipalForRequest(userData); SignInManager.SignInAsync(user, isPersistent: true, rememberBrowser: false).Wait(); + + _httpContext.SetPrincipalForRequest(owinCtx.Request.User); + return TimeSpan.FromMinutes(GlobalSettings.TimeOutInMinutes).TotalSeconds; } - - [Obsolete("This method should not be used, login is performed by the OWIN pipeline, use the overload that returns double and accepts a UserId instead")] - public virtual FormsAuthenticationTicket PerformLogin(IUser user) - { - //clear the external cookie - we do this first without owin context because we're writing cookies directly to httpcontext - // and cookie handling is different with httpcontext vs webapi and owin, normally we'd just do: - //_httpContext.GetOwinContext().Authentication.SignOut(Constants.Security.BackOfficeExternalAuthenticationType); - - var externalLoginCookie = _httpContext.Request.Cookies.Get(Constants.Security.BackOfficeExternalCookieName); - if (externalLoginCookie != null) - { - externalLoginCookie.Expires = DateTime.Now.AddYears(-1); - _httpContext.Response.Cookies.Set(externalLoginCookie); - } - - //ensure it's done for owin too - _httpContext.GetOwinContext().Authentication.SignOut(Constants.Security.BackOfficeExternalAuthenticationType); - - var ticket = _httpContext.CreateUmbracoAuthTicket(Mapper.Map(user)); - return ticket; - } - + /// /// Clears the current login for the currently logged in user /// diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index e3753ed994..0f44de7c14 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -131,14 +131,17 @@ + + + @@ -199,6 +202,10 @@ + + + + @@ -251,6 +258,7 @@ + @@ -386,6 +394,7 @@ + diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Macros/editMacro.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Macros/editMacro.aspx.cs index a3ff49e545..420002f9b3 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Macros/editMacro.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Macros/editMacro.aspx.cs @@ -46,7 +46,7 @@ namespace umbraco.cms.presentation.developer { ClientTools .SetActiveTreeType(Constants.Trees.Macros) - .SyncTree("-1,init," + _macro.Id, false); + .SyncTree("-1," + _macro.Id, false); string tempMacroAssembly = _macro.ControlAssembly ?? ""; string tempMacroType = _macro.ControlType ?? ""; @@ -291,7 +291,7 @@ namespace umbraco.cms.presentation.developer ClientTools .SetActiveTreeType(Constants.Trees.Macros) - .SyncTree("-1,init," + _macro.Id.ToInvariantString(), true); //true forces the reload + .SyncTree("-1," + _macro.Id.ToInvariantString(), true); //true forces the reload var tempMacroAssembly = macroAssembly.Text; var tempMacroType = macroType.Text; diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Packages/editPackage.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Packages/editPackage.aspx.cs index 35f36f9733..56830f8230 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Packages/editPackage.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Packages/editPackage.aspx.cs @@ -167,6 +167,13 @@ namespace umbraco.presentation.developer.packages ///*Data types */ //cms.businesslogic.datatype.DataTypeDefinition[] umbDataType = cms.businesslogic.datatype.DataTypeDefinition.GetAll(); + + // sort array by name + Array.Sort(umbDataType, delegate(cms.businesslogic.datatype.DataTypeDefinition umbDataType1, cms.businesslogic.datatype.DataTypeDefinition umbDataType2) + { + return umbDataType1.Text.CompareTo(umbDataType2.Text); + }); + //foreach (cms.businesslogic.datatype.DataTypeDefinition umbDtd in umbDataType) //{ diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Xslt/editXslt.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Xslt/editXslt.aspx.cs index 0768475133..392a914eb3 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Xslt/editXslt.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/developer/Xslt/editXslt.aspx.cs @@ -28,7 +28,7 @@ namespace umbraco.cms.presentation.developer if (!IsPostBack) { string file = Request.QueryString["file"]; - string path = BaseTree.GetTreePathFromFilePath(file); + string path = BaseTree.GetTreePathFromFilePath(file, false, tree); ClientTools .SetActiveTreeType(Constants.Trees.Xslt) .SyncTree(path, false); diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/importDocumenttype.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/importDocumenttype.aspx.cs index d934ea725b..59720e8e63 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/importDocumenttype.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/importDocumenttype.aspx.cs @@ -73,6 +73,7 @@ namespace umbraco.presentation.umbraco.dialogs private void import_Click(object sender, EventArgs e) { var xd = new XmlDocument(); + xd.XmlResolver = null; xd.Load(tempFile.Value); var userId = Security.GetUserId(); @@ -107,6 +108,7 @@ namespace umbraco.presentation.umbraco.dialogs documentTypeFile.PostedFile.SaveAs(fileName); var xd = new XmlDocument(); + xd.XmlResolver = null; xd.Load(fileName); dtName.Text = xd.DocumentElement.SelectSingleNode("//DocumentType/Info/Name").FirstChild.Value; dtAlias.Text = xd.DocumentElement.SelectSingleNode("//DocumentType/Info/Alias").FirstChild.Value; diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/notifications.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/notifications.aspx.cs index 74fc5af5e0..268e604547 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/notifications.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/notifications.aspx.cs @@ -30,7 +30,7 @@ namespace umbraco.dialogs protected void Page_Load(object sender, EventArgs e) { Button1.Text = Services.TextService.Localize("update"); - pane_form.Text = Services.TextService.Localize("notifications/editNotifications", new[] { node.Name}); + pane_form.Text = Services.TextService.Localize("notifications/editNotifications", new[] { Server.HtmlEncode(node.Name) }); } #region Web Form Designer generated code diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/rollBack.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/rollBack.aspx.cs index 03558ad847..1bf5e1b158 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/rollBack.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/rollBack.aspx.cs @@ -5,6 +5,7 @@ //using System.ComponentModel; //using System.Data; //using System.Drawing; +//using System.Linq; //using System.Web; //using System.Web.SessionState; //using System.Web.UI; @@ -119,36 +120,22 @@ // if (!IsPostBack) { // allVersions.Items.Add(new ListItem(Services.TextService.Localize("rollback/selectVersion")+ "...", "")); -// foreach (DocumentVersionList dl in currentDoc.GetVersions()) { + +// foreach (DocumentVersionList dl in currentDoc.GetVersions()) +// { +// //we don't need to show the current version +// if (dl.Version == currentDoc.Version) +// continue; +// // allVersions.Items.Add(new ListItem(dl.Text + " (" + Services.TextService.Localize("content/createDate") + ": " + dl.Date.ToShortDateString() + " " + dl.Date.ToShortTimeString() + ")", dl.Version.ToString())); // } // Button1.Text = Services.TextService.Localize("actions/rollback"); // } // } - -// #region Web Form Designer generated code -// override protected void OnInit(EventArgs e) -// { -// // -// // CODEGEN: This call is required by the ASP.NET Web Form Designer. -// // -// InitializeComponent(); -// base.OnInit(e); -// } - -// /// -// /// Required method for Designer support - do not modify -// /// the contents of this method with the code editor. -// /// -// private void InitializeComponent() -// { - -// } -// #endregion - // protected void doRollback_Click(object sender, System.EventArgs e) // { -// if (allVersions.SelectedValue.Trim() != "") { +// if (allVersions.SelectedValue.Trim() != "") +// { // Document d = new Document(int.Parse(helper.Request("nodeId"))); // d.RollBack(new Guid(allVersions.SelectedValue), Security.CurrentUser); @@ -161,6 +148,8 @@ // feedBackMsg.Text = ui.Text("rollback", "documentRolledBack", vars, new global::umbraco.BusinessLogic.User(0)) + "

" + Services.TextService.Localize("closeThisWindow") + ""; // diffPanel.Visible = false; // pl_buttons.Visible = false; +// +// ClientTools.ReloadLocationIfMatched(string.Format("/content/content/edit/{0}", d.Id)); // } // } // } diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/viewAuditTrail.aspx b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/viewAuditTrail.aspx index 1c703d25a0..159b55421f 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/viewAuditTrail.aspx +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/dialogs/viewAuditTrail.aspx @@ -7,6 +7,7 @@ diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/translation/default.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/translation/default.aspx.cs index 1995cdb580..4d7e033635 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/translation/default.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/translation/default.aspx.cs @@ -3,7 +3,6 @@ using System.Data; using System.IO; using System.Text; using System.Xml; -using Umbraco.Core; using Umbraco.Core.Services; using Umbraco.Core.IO; using System.Collections.Generic; diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/translation/details.aspx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/translation/details.aspx.cs index 8736baf6e8..79c22c3c08 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/translation/details.aspx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/translation/details.aspx.cs @@ -5,7 +5,6 @@ using Umbraco.Core; using Umbraco.Core.Services; using Umbraco.Web.Composing; using Umbraco.Web._Legacy.BusinessLogic; - namespace umbraco.presentation.umbraco.translation { public partial class details : Umbraco.Web.UI.Pages.UmbracoEnsuredPage { diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/webservices/nodeSorter.asmx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/webservices/nodeSorter.asmx.cs index 672aa151cf..9574d4e3bf 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/webservices/nodeSorter.asmx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/webservices/nodeSorter.asmx.cs @@ -168,13 +168,16 @@ namespace umbraco.presentation.webservices { var contentService = Services.ContentService; try - { - var intIds = ids.Select(int.Parse).ToArray(); - var allContent = contentService.GetByIds(intIds).ToDictionary(x => x.Id, x => x); - var sortedContent = intIds.Select(x => allContent[x]); - + { // Save content with new sort order and update db+cache accordingly - var sorted = contentService.Sort(sortedContent); + var intIds = new List(); + foreach (var stringId in ids) + { + int intId; + if (int.TryParse(stringId, out intId)) + intIds.Add(intId); + } + var sorted = contentService.Sort(intIds.ToArray()); // refresh sort order on cached xml // but no... this is not distributed - solely relying on content service & events should be enough From c2e1ba21b2e68b8fca6351968f2bf03cbfa57a4a Mon Sep 17 00:00:00 2001 From: Stephan Date: Tue, 27 Mar 2018 16:18:51 +0200 Subject: [PATCH 10/15] Port v7@2aa0dfb2c5 - WIP --- .../BackOfficeCookieAuthenticationProvider.cs | 20 +- src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 1 - src/Umbraco.Web.UI/Umbraco/config/lang/cs.xml | 8 +- src/Umbraco.Web.UI/Umbraco/config/lang/nb.xml | 22 +- .../Umbraco/config/lang/zh_tw.xml | 8 +- .../Views/Partials/Grid/Bootstrap2.cshtml | 28 +- .../Views/Partials/Grid/Bootstrap3.cshtml | 28 +- .../Views/Partials/Grid/Editors/Media.cshtml | 4 +- .../Partials/Grid/Editors/TextString.cshtml | 10 +- .../BackOfficeTours/getting-started.json | 481 +++ .../config/imageprocessor/processing.config | 1 + .../config/tinyMceConfig.Release.config | 2 +- .../config/tinyMceConfig.config | 4 +- src/Umbraco.Web.UI/config/trees.config | 4 +- .../config/umbracoSettings.Release.config | 25 +- .../config/umbracoSettings.config | 5 + .../umbraco/Install/Views/Index.cshtml | 3 +- .../umbraco/Views/Default.cshtml | 53 +- .../umbraco/config/create/UI.Release.xml | 1 + .../umbraco/config/create/UI.xml | 1 + src/Umbraco.Web.UI/umbraco/config/lang/da.xml | 516 ++- src/Umbraco.Web.UI/umbraco/config/lang/de.xml | 9 +- src/Umbraco.Web.UI/umbraco/config/lang/en.xml | 3128 ++++++++------- .../umbraco/config/lang/en_us.xml | 3346 ++++++++++------- src/Umbraco.Web.UI/umbraco/config/lang/es.xml | 2770 +++++++++----- src/Umbraco.Web.UI/umbraco/config/lang/fr.xml | 8 +- src/Umbraco.Web.UI/umbraco/config/lang/he.xml | 15 +- src/Umbraco.Web.UI/umbraco/config/lang/it.xml | 8 +- src/Umbraco.Web.UI/umbraco/config/lang/ja.xml | 8 +- src/Umbraco.Web.UI/umbraco/config/lang/ko.xml | 10 +- src/Umbraco.Web.UI/umbraco/config/lang/nl.xml | 685 +++- src/Umbraco.Web.UI/umbraco/config/lang/pl.xml | 1261 ++++++- src/Umbraco.Web.UI/umbraco/config/lang/pt.xml | 10 +- src/Umbraco.Web.UI/umbraco/config/lang/ru.xml | 296 +- src/Umbraco.Web.UI/umbraco/config/lang/sv.xml | 24 +- src/Umbraco.Web.UI/umbraco/config/lang/tr.xml | 8 +- src/Umbraco.Web.UI/umbraco/config/lang/zh.xml | 10 +- .../umbraco/dialogs/Publish.aspx.cs | 2 +- .../umbraco/dialogs/viewAuditTrail.aspx | 1 + .../umbraco/webservices/MediaUploader.ashx | 1 - .../Application/UmbracoClientManager.js | 4 +- .../umbraco_client/Dialogs/SortDialog.js | 1 + src/Umbraco.Web/Editors/ContentController.cs | 1 + .../Editors/CurrentUserController.cs | 1 + src/Umbraco.Web/Editors/DataTypeController.cs | 4 +- src/Umbraco.Web/Editors/LogController.cs | 18 +- src/Umbraco.Web/Editors/MediaController.cs | 4 +- src/Umbraco.Web/Editors/MemberController.cs | 2 + .../Editors/MemberTypeController.cs | 3 +- src/Umbraco.Web/Editors/TourController.cs | 5 +- src/Umbraco.Web/Features/DisabledFeatures.cs | 24 + src/Umbraco.Web/Features/EnabledFeatures.cs | 14 + src/Umbraco.Web/Features/UmbracoFeatures.cs | 48 + .../Models/BackOfficeTourFilter.cs | 61 - src/Umbraco.Web/Models/Trees/MenuItem.cs | 2 +- src/Umbraco.Web/Mvc/RenderRouteHandler.cs | 7 +- .../ColorPickerConfigurationEditor.cs | 13 +- .../ColorPickerPropertyEditor.cs | 14 +- .../DropDownFlexibleConfiguration.cs | 10 + .../DropDownFlexibleConfigurationEditor.cs | 60 + .../DropDownFlexiblePropertyEditor.cs | 22 + .../DropDownMultiplePropertyEditor.cs | 2 +- .../DropDownMultipleWithKeysPropertyEditor.cs | 2 +- .../PropertyEditors/DropDownPropertyEditor.cs | 2 +- .../DropDownWithKeysPropertyEditor.cs | 2 +- .../PropertyEditors/TextboxConfiguration.cs | 4 +- .../Runtime/WebRuntimeComponent.cs | 3 +- src/Umbraco.Web/Search/UmbracoTreeSearcher.cs | 2 +- .../Trees/FileSystemTreeController.cs | 94 +- src/Umbraco.Web/Trees/MacroTreeController.cs | 73 - src/Umbraco.Web/Trees/UserTreeController.cs | 2 +- src/Umbraco.Web/Trees/XsltTreeController.cs | 16 +- src/Umbraco.Web/Umbraco.Web.csproj | 9 +- .../WebApi/Binders/MemberBinder.cs | 10 +- .../Filters/ContentItemValidationHelper.cs | 1 + .../Filters/FeatureAuthorizeAttribute.cs | 14 +- .../umbraco/Trees/BaseTree.cs | 16 +- .../developer/Packages/editPackage.aspx.cs | 8 +- .../umbraco/developer/Xslt/editXslt.aspx.cs | 5 +- .../umbraco/translation/default.aspx.cs | 5 +- 80 files changed, 8951 insertions(+), 4462 deletions(-) create mode 100644 src/Umbraco.Web.UI/config/BackOfficeTours/getting-started.json delete mode 100644 src/Umbraco.Web.UI/umbraco/webservices/MediaUploader.ashx create mode 100644 src/Umbraco.Web/Features/DisabledFeatures.cs create mode 100644 src/Umbraco.Web/Features/EnabledFeatures.cs create mode 100644 src/Umbraco.Web/Features/UmbracoFeatures.cs delete mode 100644 src/Umbraco.Web/Models/BackOfficeTourFilter.cs create mode 100644 src/Umbraco.Web/PropertyEditors/DropDownFlexibleConfiguration.cs create mode 100644 src/Umbraco.Web/PropertyEditors/DropDownFlexibleConfigurationEditor.cs create mode 100644 src/Umbraco.Web/PropertyEditors/DropDownFlexiblePropertyEditor.cs delete mode 100644 src/Umbraco.Web/Trees/MacroTreeController.cs diff --git a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs index 686cefcdf8..cb0ce2b147 100644 --- a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; +using Umbraco.Core.Composing; using Umbraco.Core.Configuration; using Umbraco.Core.Services; @@ -14,14 +15,9 @@ namespace Umbraco.Core.Security { public class BackOfficeCookieAuthenticationProvider : CookieAuthenticationProvider { - private readonly IUserService _userService; - private readonly IRuntimeState _runtimeState; - - public BackOfficeCookieAuthenticationProvider(IUserService userService, IRuntimeState runtimeState) - { - _userService = userService; - _runtimeState = runtimeState; - } + // fixme inject + private IUserService UserService => Current.Services.UserService; + private IRuntimeState RuntimeState => Current.RuntimeState; public override void ResponseSignIn(CookieResponseSignInContext context) { @@ -30,8 +26,8 @@ namespace Umbraco.Core.Security //generate a session id and assign it //create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one - var session = _runtimeState.Level == RuntimeLevel.Run - ? _userService.CreateLoginSession((int)backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) + var session = RuntimeState.Level == RuntimeLevel.Run + ? UserService.CreateLoginSession((int)backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) : Guid.NewGuid(); backOfficeIdentity.UserData.SessionId = session.ToString(); @@ -49,7 +45,7 @@ namespace Umbraco.Core.Security var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out var guidSession)) { - _userService.ClearLoginSession(guidSession); + UserService.ClearLoginSession(guidSession); } } @@ -100,7 +96,7 @@ namespace Umbraco.Core.Security /// protected virtual async Task EnsureValidSessionId(CookieValidateIdentityContext context) { - if (_runtimeState.Level == RuntimeLevel.Run) + if (RuntimeState.Level == RuntimeLevel.Run) await SessionIdValidator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context); } diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 650ed00f0b..add06f81b6 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -949,7 +949,6 @@ - Designer diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/cs.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/cs.xml index 1da4c78605..d8c9f8570f 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/cs.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/cs.xml @@ -771,7 +771,7 @@ Creation date Třídění bylo ukončeno. Abyste nastavili, jak mají být položky seřazeny, přetáhněte jednotlivé z nich nahoru či dolů. Anebo klikněte na hlavičku sloupce pro setřídění celé kolekce -
Během třídění nezavírejte toto okno]]>
+ Publikování bylo zrušeno doplňkem třetí strany @@ -848,6 +848,12 @@ Šablona + Rich Text Editor + Image + Macro + Embed + Headline + Quote Choose type of content Choose a layout Add a row diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/nb.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/nb.xml index 2c9b5d844b..dee52cff5c 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/nb.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/nb.xml @@ -83,6 +83,7 @@ Tilbake til listen Lagre Lagre og publiser + Lagre og planlegge Lagre og send til publisering Forhåndsvis Forhåndsvisning er deaktivert siden det ikke er angitt noen mal @@ -422,7 +423,7 @@ Hvilken side skal vises etter at skjemaet er sendt Størrelse Sorter - Submit + Send Type Søk... Opp @@ -439,6 +440,17 @@ Ja Mappe Søkeresultater + Sorter + Avslutt sortering + Eksempel + Bytt passord + til + Listevisning + Lagrer... + nåværende + Innbygging + Hent + valgt Bakgrunnsfarge @@ -733,7 +745,7 @@ Vennlig hilsen Umbraco roboten Creation date Sortering ferdig. Dra elementene opp eller ned for å arrangere dem. Du kan også klikke kolonneoverskriftene for å sortere alt på en gang. -
Ikke lukk dette vinduet under sortering]]>
+ En feil oppsto @@ -819,6 +831,12 @@ Vennlig hilsen Umbraco roboten Mal + Rich Text Editor + Image + Macro + Embed + Headline + Quote Sett inn element Velg layout Legg til rad diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/zh_tw.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/zh_tw.xml index 03933f34ff..cdcf095aae 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/zh_tw.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/zh_tw.xml @@ -905,7 +905,7 @@ 增添時間 排序完成。 上下拖拽項目或按一下列頭進行排序 -
排序中請不要關閉視窗。]]>
+ 驗證 @@ -995,6 +995,12 @@ 範本 + Rich Text Editor + Image + Macro + Embed + Headline + Quote 選擇內容類別 選擇排列方式 新增一行 diff --git a/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap2.cshtml b/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap2.cshtml index 8b189ae1a0..1437d4f14b 100644 --- a/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap2.cshtml +++ b/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap2.cshtml @@ -60,21 +60,29 @@ JObject cfg = contentItem.config; if(cfg != null) - foreach (JProperty property in cfg.Properties()) { - attrs.Add(property.Name + "='" + property.Value.ToString() + "'"); + foreach (JProperty property in cfg.Properties()) + { + var propertyValue = HttpUtility.HtmlAttributeEncode(property.Value.ToString()); + attrs.Add(property.Name + "=\"" + propertyValue + "\""); } - + JObject style = contentItem.styles; - if (style != null) { - var cssVals = new List(); - foreach (JProperty property in style.Properties()) - cssVals.Add(property.Name + ":" + property.Value.ToString() + ";"); + if (style != null) { + var cssVals = new List(); + foreach (JProperty property in style.Properties()) + { + var propertyValue = property.Value.ToString(); + if (string.IsNullOrWhiteSpace(propertyValue) == false) + { + cssVals.Add(property.Name + ":" + propertyValue + ";"); + } + } - if (cssVals.Any()) - attrs.Add("style='" + string.Join(" ", cssVals) + "'"); + if (cssVals.Any()) + attrs.Add("style=\"" + HttpUtility.HtmlAttributeEncode(string.Join(" ", cssVals)) + "\""); } - + return new MvcHtmlString(string.Join(" ", attrs)); } } \ No newline at end of file diff --git a/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap3.cshtml b/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap3.cshtml index e672aa2a11..7b4f602b26 100644 --- a/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap3.cshtml +++ b/src/Umbraco.Web.UI/Views/Partials/Grid/Bootstrap3.cshtml @@ -60,21 +60,29 @@ JObject cfg = contentItem.config; if(cfg != null) - foreach (JProperty property in cfg.Properties()) { - attrs.Add(property.Name + "='" + property.Value.ToString() + "'"); + foreach (JProperty property in cfg.Properties()) + { + var propertyValue = HttpUtility.HtmlAttributeEncode(property.Value.ToString()); + attrs.Add(property.Name + "=\"" + propertyValue + "\""); } - + JObject style = contentItem.styles; - if (style != null) { - var cssVals = new List(); - foreach (JProperty property in style.Properties()) - cssVals.Add(property.Name + ":" + property.Value.ToString() + ";"); + if (style != null) { + var cssVals = new List(); + foreach (JProperty property in style.Properties()) + { + var propertyValue = property.Value.ToString(); + if (string.IsNullOrWhiteSpace(propertyValue) == false) + { + cssVals.Add(property.Name + ":" + propertyValue + ";"); + } + } - if (cssVals.Any()) - attrs.Add("style='" + string.Join(" ", cssVals) + "'"); + if (cssVals.Any()) + attrs.Add("style=\"" + HttpUtility.HtmlAttributeEncode(string.Join(" ", cssVals)) + "\""); } - + return new MvcHtmlString(string.Join(" ", attrs)); } } \ No newline at end of file diff --git a/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/Media.cshtml b/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/Media.cshtml index 09d04219f2..ea79ce41ad 100644 --- a/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/Media.cshtml +++ b/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/Media.cshtml @@ -13,8 +13,10 @@ url += "&mode=crop"; } } + + var altText = Model.value.altText ?? Model.value.caption ?? string.Empty; - @Model.value.caption + @altText if (Model.value.caption != null) { diff --git a/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/TextString.cshtml b/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/TextString.cshtml index 0cac4eb1ff..4cf2e73658 100644 --- a/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/TextString.cshtml +++ b/src/Umbraco.Web.UI/Views/Partials/Grid/Editors/TextString.cshtml @@ -4,9 +4,13 @@ @if (Model.editor.config.markup != null) { string markup = Model.editor.config.markup.ToString(); - - markup = markup.Replace("#value#", Model.value.ToString()); - markup = markup.Replace("#style#", Model.editor.config.style.ToString()); + var umbracoHelper = new UmbracoHelper(UmbracoContext.Current); + markup = markup.Replace("#value#", umbracoHelper.ReplaceLineBreaksForHtml(HttpUtility.HtmlEncode(Model.value.ToString()))); + + if (Model.editor.config.style != null) + { + markup = markup.Replace("#style#", Model.editor.config.style.ToString()); + } @Html.Raw(markup) diff --git a/src/Umbraco.Web.UI/config/BackOfficeTours/getting-started.json b/src/Umbraco.Web.UI/config/BackOfficeTours/getting-started.json new file mode 100644 index 0000000000..836b7a965c --- /dev/null +++ b/src/Umbraco.Web.UI/config/BackOfficeTours/getting-started.json @@ -0,0 +1,481 @@ +[ + { + "name": "Introduction", + "alias": "umbIntroIntroduction", + "group": "Getting Started", + "groupOrder": 100, + "allowDisable": true, + "requiredSections": [ + "content", + "media", + "settings", + "developer", + "users", + "member", + "forms" + ], + "steps": [ + { + "title": "Welcome to Umbraco - The Friendly CMS", + "content": "

Thank you for choosing Umbraco - we think this could be the beginning of something beautiful. While it may feel overwhelming at first, we've done a lot to make the learning curve as smooth and fast as possible.

In this quick tour we will introduce you to the main areas of Umbraco and show you how to best get started.

If you don't want to take the tour now you can always start it by opening the Help drawer in the bottom left corner.

", + "type": "intro" + }, + { + "element": "#applications", + "elementPreventClick": true, + "title": "Main Menu", + "content": "This is the main menu in Umbraco backoffice. Here you can navigate betweeen the different sections, see your user profile and open the help drawer", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='section-content']", + "elementPreventClick": true, + "title": "Sections", + "content": "Each area in Umbraco is called a Section. Right now you are in the Content section, when you want to go to another section simply click on the appropriate icon in the main menu and you'll be there in no time.", + "backdropOpacity": 0.6 + }, + { + "element": "#tree", + "elementPreventClick": true, + "title": "The Tree", + "content": "

This is the Tree and is the main navigation inside a section.

In the Content section the tree is called the Content tree and here you can navigate the content of your website.

" + }, + { + "element": "[data-element='editor-container']", + "elementPreventClick": true, + "title": "Dashboards", + "content": "

A dashboard is the main view you are presented with when entering a section within the backoffice, and can be used to show valuable information to the users of the system.

Notice that some sections have multiple dashboards.

" + }, + { + "element": "[data-element='global-search-field']", + "title": "Search", + "content": "The search allows you to quickly find whatever you're looking for across sections within Umbraco." + }, + { + "element": "#applications [data-element='section-user']", + "title": "User profile", + "content": "Click on your user avatar to open the user profile dialog.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element~='overlay-user']", + "elementPreventClick": true, + "title": "User profile", + "content": "

Here you can see details about your user, what Umbraco version the site is running, change your password and log out of Umbraco.

In the User section you will be able to do more advanced user management.

" + }, + { + "element": "[data-element~='overlay-user'] [data-element='button-overlayClose']", + "title": "User profile", + "content": "Let's close the user profile again", + "event": "click" + }, + { + "element": "#applications [data-element='section-help']", + "title": "Help", + "content": "If you ever find yourself in trouble click here to open the help drawer.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='drawer']", + "elementPreventClick": true, + "title": "Help", + "content": "

In the help drawer you will find articles and videos related to the section you are using.

This is also where you will find the next tour on how to get started with Umbraco.

", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='drawer'] [data-element='help-tours']", + "title": "Tours", + "content": "To continue your journey on getting started with Umbraco, you can find more tours right here." + } + ] + }, + { + "name": "Create document type", + "alias": "umbIntroCreateDocType", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "content", + "media", + "settings", + "developer", + "users", + "member", + "forms" + ], + "steps": [ + { + "title": "Create your first Document Type", + "content": "

Step 1 of any site is to create a Document Type.
A Document Type is a template for content. For each type of content you want to create you'll create a Document Type. This will define were content based on this Document Type can be created, how many properties it holds and what the input method should be for these properties.

When you have at least one Document type in place you can start creating content and this content can the be used in a template.

In this tour you will learn how to set up a basic Document Type with a property to enter a short text.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-settings']", + "title": "Navigate to the Settings sections", + "content": "In the Settings section you can create and manage Document types.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-item-documentTypes']", + "title": "Create Document Type", + "content": "

Hover the Document Type tree and click the three small dots to open the context menu.

", + "event": "click", + "eventElement": "#tree [data-element='tree-item-documentTypes'] [data-element='tree-item-options']" + }, + { + "element": "#dialog [data-element='action-documentType']", + "title": "Create Document Type", + "content": "

Click Document Type to create a new document type with a template. The template will be automatically created and set as the default template for this Document Type

You will use the template in a later tour render content.

", + "event": "click" + }, + { + "element": "[data-element='editor-name-field']", + "title": "Enter a name", + "content": "

Your Document Type needs a name. Enter Home Page in the field and click Next.", + "view": "doctypename" + }, + { + "element": "[data-element='editor-description']", + "title": "Enter a description", + "content": "

A description helps to pick the right document type when creating content.

Write a description to our Home page. It could be:

The home page of the website

" + }, + { + "element": "[data-element='group-add']", + "title": "Add tab", + "content": "Tabs are used to organize properties on content in the Content section. Click Add new tab to add a tab.", + "event": "click" + }, + { + "element": "[data-element='group-name-field']", + "title": "Name the tab", + "content": "

Enter Home in the tab name.

You can name a tab anything you want and if you have a lot of properties it can be useful to add multiple tabs.

", + "view": "tabName" + }, + { + "element": "[data-element='property-add']", + "title": "Add a property", + "content": "

Properties are the different input fields on a content page.

On our Home Page we wan't to add a welcome text.

Click Add property to open the property dialog.

", + "event": "click" + }, + { + "element": "[data-element~='overlay-property-settings'] [data-element='property-name']", + "title": "Name the property", + "content": "Enter Welcome Text as the name for the property.", + "view": "propertyname" + }, + { + "element": "[data-element~='overlay-property-settings'] [data-element='property-description']", + "title": "Enter a description", + "content": "

A description will help to fill in the right content.

Enter a description for the property editor. It could be:

Write a nice introduction text so the visitors feel welcome

" + }, + { + "element": "[data-element~='overlay-property-settings'] [data-element='editor-add']", + "title": "Add editor", + "content": "When you add an editor you choose what the input method for this property will be. Click Add editor to open the editor picker dialog.", + "event": "click" + }, + { + "element": "[data-element~='overlay-editor-picker']", + "elementPreventClick": true, + "title": "Editor picker", + "content": "

In the editor picker dialog we can pick one of the many build in editor.

You can choose from preconfigured data types (Reuse) or create a new configuration (Available editors)

" + }, + { + "element": "[data-element~='overlay-editor-picker'] [data-element='editor-Textarea']", + "title": "Select editor", + "content": "Select the Textarea editor. This will add a textarea to the Welcome Text property.", + "event": "click" + }, + { + "element": "[data-element~='overlay-editor-settings']", + "elementPreventClick": true, + "title": "Editor settings", + "content": "Each property editor can have individual settings. For the textarea editor you can set a charachter limit but in this case it is not needed" + }, + { + "element": "[data-element~='overlay-editor-settings'] [data-element='button-overlaySubmit']", + "title": "Save editor", + "content": "Click Submit to save the editor.", + "event": "click" + }, + { + "element": "[data-element~='overlay-property-settings'] [data-element='button-overlaySubmit']", + "title": "Add property to document type", + "content": "Click Submit to add the property to the document type.", + "event": "click" + }, + { + "element": "[data-element='button-save']", + "title": "Save the document type", + "content": "All we need now is to save the document type. Click Save to create and save your new document type.", + "event": "click" + } + ] + }, + { + "name": "Create Content", + "alias": "umbIntroCreateContent", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "content", + "media", + "settings", + "developer", + "users", + "member", + "forms" + ], + "steps": [ + { + "title": "Creating your first content node", + "content": "

In this tour you will learn how to create the home page for your website. It will use the Home Page Document type you created in the previous tour.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-content']", + "title": "Navigate to the Content section", + "content": "

In the Content section you can create and manage the content of the website.

The Content section contains the content of your website. Content is displayed as nodes in the content tree.

", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='tree-root']", + "title": "Open context menu", + "content": "

Open the context menu by hovering the root of the content section.

Now click the three small dots to the right.

", + "event": "click", + "eventElement": "[data-element='tree-root'] [data-element='tree-item-options']" + }, + { + "element": "[data-element='action-create-homePage']", + "title": "Create Home page", + "content": "

The context menu shows you all the actions that are available on a node

Click on Home Page to create a new page of type Home Page.

", + "event": "click" + }, + { + "element": "[data-element='editor-content'] [data-element='editor-name-field']", + "title": "Give your new page a name", + "content": "

Our new page needs a name. Enter Home in the field and click Next.

", + "view": "nodename" + }, + { + "element": "[data-element='editor-content'] [data-element='property-welcomeText']", + "title": "Add a welcome text", + "content": "

Add content to the Welcome Text field

If you don't have any ideas here is a start:

I am learning Umbraco. High Five I Rock #H5IR
.

" + }, + { + "element": "[data-element='editor-content'] [data-element='button-saveAndPublish']", + "title": "Save and Publish", + "content": "

Now click the Save and publish button to save and publish your changes.

", + "event": "click" + } + ] + }, + { + "name": "Render in template", + "alias": "umbIntroRenderInTemplate", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "content", + "media", + "settings", + "developer", + "users", + "member", + "forms" + ], + "steps": [ + { + "title": "Render your content in a template", + "content": "

Templating in Umbraco builds on the concept of Razor Views from asp.net MVC. - This tour is a sneak peak on how to write templates in Umbraco.

In this tour you will learn how to render content from the Home Page document type so you can see the content added to our Home content page.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-settings']", + "title": "Navigate to the Settings section", + "content": "

In the Settings section you will find all the templates

It is of course also possible to edit all your code files in your favorite code editor.

", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-item-templates']", + "title": "Expand the Templates node", + "content": "

To see all our templates click the small triangle to the left of the templates node.

", + "event": "click", + "eventElement": "#tree [data-element='tree-item-templates'] [data-element='tree-item-expand']", + "view": "templatetree" + }, + { + "element": "#tree [data-element='tree-item-templates'] [data-element='tree-item-Home Page']", + "title": "Open Home template", + "content": "

Click the Home Page template to open and edit it.

", + "eventElement": "#tree [data-element='tree-item-templates'] [data-element='tree-item-Home Page'] a.umb-tree-item__label", + "event": "click" + }, + { + "element": "[data-element='editor-templates'] [data-element='code-editor']", + "title": "Edit template", + "content": "

The template can be edited here or in your favorite code editor.

To render the field from the document type add the following to the template:

<h1>@Model.Content.Name</h1>
<p>@Model.Content.WelcomeText</p>

" + }, + { + "element": "[data-element='editor-templates'] [data-element='button-save']", + "title": "Save the template", + "content": "Click the Save button and your template will be saved.", + "event": "click" + } + ] + }, + { + "name": "View Home page", + "alias": "umbIntroViewHomePage", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "content", + "media", + "settings", + "developer", + "users", + "member", + "forms" + ], + "steps": [ + { + "title": "View your Umbraco site", + "content": "

Our three main components to a page is done: Document type, Template, and Content - it is now time to see the result.

In this tour you will learn how to see your published website.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-content']", + "title": "Navigate to the content sections", + "content": "In the Content section you will find the content of our website.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-item-Home']", + "title": "Open the Home page", + "content": "

Click the Home page to open it

", + "event": "click", + "eventElement": "#tree [data-element='tree-item-Home'] a.umb-tree-item__label" + }, + { + "element": "[data-element='editor-content'] [data-element='tab-_umb_infoTab']", + "title": "Info", + "content": "

Under the info tab you will find the default information about a content item.

", + "event": "click" + }, + { + "element": "[data-element='editor-content'] [data-element='node-info-urls']", + "title": "Open page", + "content": "

Click the Link to document to view your page.

Tip: Click the preview button in the bottom right corner to preview changes without publishing them.

", + "event": "click", + "eventElement": "[data-element='editor-content'] [data-element='node-info-urls'] a[target='_blank']" + } + ] + }, + { + "name": "The Media library", + "alias": "umbIntroMediaSection", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "content", + "media", + "settings", + "developer", + "users", + "member", + "forms" + ], + "steps": [ + { + "title": "How to use the media library", + "content": "

A website would be boring without media content. In Umbraco you can manage all your images, documents, videos etc. in the Media section. Here you can upload and organise your media items and see details about each item.

In this tour you will learn how to upload and organise your Media library in Umbraco. It will also show you how to view details about a specific media item.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-media']", + "title": "Navigate to the Media section", + "content": "The media section is where you manage all your media items.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-root']", + "title": "Create a new folder", + "content": "

First create a folder for your images. Hover the media root node and click the three small dots on the right side of the item.

", + "event": "click", + "eventElement": "#tree [data-element='tree-root'] [data-element='tree-item-options']" + }, + { + "element": "#dialog [data-element='action-Folder']", + "title": "Create a new folder", + "content": "

Select the Folder option to select the type folder.

", + "event": "click" + }, + { + "element": "[data-element='editor-media'] [data-element='editor-name-field']", + "title": "Enter a name", + "content": "

Enter My Images in the field.

", + "view": "foldername" + }, + { + "element": "[data-element='editor-media'] [data-element='button-save']", + "title": "Save the folder", + "content": "

Click the Save button to create the new folder

", + "event": "click" + }, + { + "element": "[data-element='editor-media'] [data-element='dropzone']", + "title": "Upload images", + "content": "

In the upload area you can upload your media items.

Click the Click here to choose files-button and select a couple of images on your computer and upload them.

", + "view": "uploadimages" + }, + { + "element": "[data-element='editor-media'] [data-element='media-grid-item-0']", + "title": "View media item details", + "content": "Hover the media item and Click the purple bar to view details about the media item", + "event": "click", + "eventElement": "[data-element='editor-media'] [data-element='media-grid-item-0'] [data-element='media-grid-item-edit']" + }, + { + "element": "[data-element='editor-media'] [data-element='property-umbracoFile']", + "elementPreventClick": true, + "title": "The uploaded image", + "content": "

Here you can see the image you have uploaded.

" + }, + { + "element": "[data-element='editor-media'] [data-element='property-umbracoBytes']", + "title": "Image size", + "content": "

You will also find other details about the image, like the size.

Media items work in much the same way as content. So you can add extra properties to an image by creating or editing the Media types in the Settings section.

" + }, + { + "element": "[data-element='editor-media'] [data-element='tab-_umb_infoTab']", + "title": "Info", + "content": "Like the content section you can also find default information about the media item. You will find these under the info tab.", + "event": "click" + }, + { + "element": "[data-element='editor-media'] [data-element='node-info-urls']", + "title": "Link to media", + "content": "The path to the media item..." + }, + { + "element": "[data-element='editor-media'] [data-element='node-info-update-date']", + "title": "Last edited", + "content": "...and information about when the media item has been created and edited." + }, + { + "element": "[data-element='editor-container']", + "elementPreventClick": true, + "title": "Using media items", + "content": "You can reference a media item directly in a template by using the path or try adding a Media Picker to a document type property so you can select media items from the content section." + } + ] + } +] diff --git a/src/Umbraco.Web.UI/config/imageprocessor/processing.config b/src/Umbraco.Web.UI/config/imageprocessor/processing.config index b6813cff77..a52661a196 100644 --- a/src/Umbraco.Web.UI/config/imageprocessor/processing.config +++ b/src/Umbraco.Web.UI/config/imageprocessor/processing.config @@ -15,6 +15,7 @@ + diff --git a/src/Umbraco.Web.UI/config/tinyMceConfig.Release.config b/src/Umbraco.Web.UI/config/tinyMceConfig.Release.config index 1811957e7c..8c39f70e51 100644 --- a/src/Umbraco.Web.UI/config/tinyMceConfig.Release.config +++ b/src/Umbraco.Web.UI/config/tinyMceConfig.Release.config @@ -248,7 +248,7 @@ img[id|dir|lang|longdesc|usemap|style|class|src|onmouseover|onmouseout|border|al thead[id|class],tfoot[id|class],#td[id|lang|dir|class|colspan|rowspan|width|height|align|valign|style|bgcolor|background|bordercolor|scope], -th[id|lang|dir|class|colspan|rowspan|width|height|align|valign|style|scope],caption[id|lang|dir|class|style],-div[id|dir|class|align|style], -span[class|align|style],-pre[class|align|style],address[class|align|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style], --h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align|style],hr[class|style], +-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align|style],hr[class|style],small[class|style], dd[id|class|title|style|dir|lang],dl[id|class|title|style|dir|lang],dt[id|class|title|style|dir|lang],object[class|id|width|height|codebase|*], param[name|value|_value|class],embed[type|width|height|src|class|*],map[name|class],area[shape|coords|href|alt|target|class],bdo[class],button[class],iframe[*]]]> diff --git a/src/Umbraco.Web.UI/config/tinyMceConfig.config b/src/Umbraco.Web.UI/config/tinyMceConfig.config index 1811957e7c..f45bdc7f49 100644 --- a/src/Umbraco.Web.UI/config/tinyMceConfig.config +++ b/src/Umbraco.Web.UI/config/tinyMceConfig.config @@ -248,7 +248,7 @@ img[id|dir|lang|longdesc|usemap|style|class|src|onmouseover|onmouseout|border|al thead[id|class],tfoot[id|class],#td[id|lang|dir|class|colspan|rowspan|width|height|align|valign|style|bgcolor|background|bordercolor|scope], -th[id|lang|dir|class|colspan|rowspan|width|height|align|valign|style|scope],caption[id|lang|dir|class|style],-div[id|dir|class|align|style], -span[class|align|style],-pre[class|align|style],address[class|align|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style], --h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align|style],hr[class|style], +-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align|style],hr[class|style],small[class|style], dd[id|class|title|style|dir|lang],dl[id|class|title|style|dir|lang],dt[id|class|title|style|dir|lang],object[class|id|width|height|codebase|*], param[name|value|_value|class],embed[type|width|height|src|class|*],map[name|class],area[shape|coords|href|alt|target|class],bdo[class],button[class],iframe[*]]]> @@ -271,4 +271,4 @@ param[name|value|_value|class],embed[type|width|height|src|class|*],map[name|cla } - \ No newline at end of file + diff --git a/src/Umbraco.Web.UI/config/trees.config b/src/Umbraco.Web.UI/config/trees.config index c97cf5363d..b647bcbcb6 100644 --- a/src/Umbraco.Web.UI/config/trees.config +++ b/src/Umbraco.Web.UI/config/trees.config @@ -22,7 +22,7 @@ - + @@ -38,5 +38,5 @@ - + \ No newline at end of file diff --git a/src/Umbraco.Web.UI/config/umbracoSettings.Release.config b/src/Umbraco.Web.UI/config/umbracoSettings.Release.config index 4e0c24434c..3f06c398f5 100644 --- a/src/Umbraco.Web.UI/config/umbracoSettings.Release.config +++ b/src/Umbraco.Web.UI/config/umbracoSettings.Release.config @@ -1,13 +1,17 @@ - - + + + + + + @@ -38,10 +42,6 @@ In Preview Mode - click to end]]> - - - 1800 - - assets/img/installer.jpg + assets/img/installer.jpg + diff --git a/src/Umbraco.Web.UI/config/umbracoSettings.config b/src/Umbraco.Web.UI/config/umbracoSettings.config index b98d36a9a4..9b06e2924d 100644 --- a/src/Umbraco.Web.UI/config/umbracoSettings.config +++ b/src/Umbraco.Web.UI/config/umbracoSettings.config @@ -1,5 +1,10 @@ + + + + + diff --git a/src/Umbraco.Web.UI/umbraco/Install/Views/Index.cshtml b/src/Umbraco.Web.UI/umbraco/Install/Views/Index.cshtml index 666f6a5b5a..bfbf5c4182 100644 --- a/src/Umbraco.Web.UI/umbraco/Install/Views/Index.cshtml +++ b/src/Umbraco.Web.UI/umbraco/Install/Views/Index.cshtml @@ -1,4 +1,5 @@ -@using Umbraco.Web +@using Umbraco.Core.Configuration +@using Umbraco.Web @using Umbraco.Web.Install.Controllers @{ Layout = null; diff --git a/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml b/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml index 7b1f83115f..9f1ad8dcfa 100644 --- a/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml +++ b/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml @@ -42,30 +42,56 @@ + Umbraco @Html.RenderCssHere( new BasicPath("Umbraco", IOHelper.ResolveUrl(SystemDirectories.Umbraco)), new BasicPath("UmbracoClient", IOHelper.ResolveUrl(SystemDirectories.UmbracoClient))) - - - -
+ - +
+ + + + +
-
-
-
-
+ - +
+
+
+ + + + + +
+
+ + + + + + @Html.BareMinimumServerVariablesScript(Url, Url.Action("ExternalLogin", "BackOffice", new { area = ViewBag.UmbracoPath }))