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 2cb8d8dfbd..722f368692 100644 --- a/src/NuGet.Config +++ b/src/NuGet.Config @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/src/Umbraco.Core/Auditing/AuditEventsComponent.cs b/src/Umbraco.Core/Auditing/AuditEventsComponent.cs new file mode 100644 index 0000000000..57457f9241 --- /dev/null +++ b/src/Umbraco.Core/Auditing/AuditEventsComponent.cs @@ -0,0 +1,353 @@ +using System; +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 AuditEventsComponent : UmbracoComponentBase, IUmbracoCoreComponent + { + private IAuditService _auditService; + private IUserService _userService; + private IEntityService _entityService; + + private IUser CurrentPerformingUser + { + get + { + var identity = Thread.CurrentPrincipal?.GetUmbracoIdentity(); + return identity == null + ? new User { Id = 0, Name = "SYSTEM", Email = "" } + : _userService.GetUserById(Convert.ToInt32(identity.Id)); + } + } + + private IUser GetPerformingUser(int userId) + { + var found = userId >= 0 ? _userService.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; + } + } + + public void Initialize(IAuditService auditService, IUserService userService, IEntityService entityService) + { + _auditService = auditService; + _userService = userService; + _entityService = 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.SavedUserGroup += 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); + _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}"); + } + } + + 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); + _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}"); + } + } + + private void OnMemberExported(IMemberService sender, ExportedMemberEventArgs exportedMemberEventArgs) + { + var performingUser = CurrentPerformingUser; + var member = exportedMemberEventArgs.Member; + + _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"); + } + + 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).GetWereDirtyProperties()); + 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}"); + } + + _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}"); + + // now audit the users that have changed + + foreach (var user in groupWithUser.RemovedUsers) + { + _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})"); + } + + foreach (var user in groupWithUser.AddedUsers) + { + _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})"); + } + } + } + + 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 = _entityService.Get(perm.EntityId); + + _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}\""); + } + } + + 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).GetWereDirtyProperties()); + + _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)}"); + } + } + + private void OnDeletedMember(IMemberService sender, DeleteEventArgs deleteEventArgs) + { + var performingUser = CurrentPerformingUser; + var members = deleteEventArgs.DeletedEntities; + foreach (var member in members) + { + _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)}"); + } + } + + 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).GetWereDirtyProperties()); + + _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)}"); + } + } + + private void OnDeletedUser(IUserService sender, DeleteEventArgs deleteEventArgs) + { + var performingUser = CurrentPerformingUser; + var affectedUsers = deleteEventArgs.DeletedEntities; + foreach (var affectedUser in affectedUsers) + _auditService.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 = _userService.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 = _userService.GetUserById(affectedId); + affectedDetails = affectedUser == null + ? $"User UNKNOWN:{affectedId}" + : $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}"; + } + + _auditService.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..cc555afe55 --- /dev/null +++ b/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs @@ -0,0 +1,62 @@ +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; } + + /// + /// Gets the second type. + /// + public Type Type2 { get; } + + /// + public bool Equals(CompositeTypeTypeKey other) + { + return Type1 == other.Type1 && Type2 == other.Type2; + } + + /// + public override bool Equals(object obj) + { + var other = obj is CompositeTypeTypeKey key ? key : default; + 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/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/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.Core/Composing/CompositionRoots/RepositoryCompositionRoot.cs b/src/Umbraco.Core/Composing/CompositionRoots/RepositoryCompositionRoot.cs index c72ecb528f..56538a8058 100644 --- a/src/Umbraco.Core/Composing/CompositionRoots/RepositoryCompositionRoot.cs +++ b/src/Umbraco.Core/Composing/CompositionRoots/RepositoryCompositionRoot.cs @@ -37,6 +37,7 @@ namespace Umbraco.Core.Composing.CompositionRoots // repositories container.RegisterSingleton(); + container.RegisterSingleton(); container.RegisterSingleton(); container.RegisterSingleton(); container.RegisterSingleton(); @@ -67,6 +68,7 @@ namespace Umbraco.Core.Composing.CompositionRoots container.RegisterSingleton(); container.RegisterSingleton(); container.RegisterSingleton(); + container.RegisterSingleton(); // repositories that depend on a filesystem // these have an annotated ctor parameter to pick the right file system diff --git a/src/Umbraco.Core/Composing/CompositionRoots/ServicesCompositionRoot.cs b/src/Umbraco.Core/Composing/CompositionRoots/ServicesCompositionRoot.cs index e3d7bca07d..d126cd6aa3 100644 --- a/src/Umbraco.Core/Composing/CompositionRoots/ServicesCompositionRoot.cs +++ b/src/Umbraco.Core/Composing/CompositionRoots/ServicesCompositionRoot.cs @@ -51,6 +51,7 @@ namespace Umbraco.Core.Composing.CompositionRoots container.RegisterSingleton(); container.RegisterSingleton(); container.RegisterSingleton(); + container.RegisterSingleton(); container.Register(factory => { var mainLangFolder = new DirectoryInfo(IOHelper.MapPath(SystemDirectories.Umbraco + "/config/lang/")); 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..f3cc8e86ff 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,7 +30,6 @@ 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(); @@ -37,6 +38,8 @@ namespace Umbraco.Core.Composing private string _currentAssembliesHash; private IEnumerable _assemblies; private bool _reportedChange; + private static LocalTempStorage _localTempStorage = LocalTempStorage.Unknown; + private static string _fileBasePath; /// /// Initializes a new instance of the class. @@ -49,13 +52,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 +63,9 @@ 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); + var typesListFilePath = GetTypesListFilePath(); + if (File.Exists(typesListFilePath)) + File.Delete(typesListFilePath); WriteCacheTypesHash(); } @@ -77,7 +75,9 @@ 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); + var typesListFilePath = GetTypesListFilePath(); + if (File.Exists(typesListFilePath)) + File.Delete(typesListFilePath); // always set to true if we're not detecting (generally only for testing) RequiresRescanning = true; @@ -135,10 +135,10 @@ namespace Umbraco.Core.Composing if (_cachedAssembliesHash != null) return _cachedAssembliesHash; - var filePath = GetTypesHashFilePath(); - if (File.Exists(filePath) == false) return string.Empty; + var typesHashFilePath = GetTypesHashFilePath(); + 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 +177,8 @@ namespace Umbraco.Core.Composing /// private void WriteCacheTypesHash() { - var filePath = GetTypesHashFilePath(); - File.WriteAllText(filePath, CurrentAssembliesHash, Encoding.UTF8); + var typesHashFilePath = GetTypesHashFilePath(); + File.WriteAllText(typesHashFilePath, CurrentAssembliesHash, Encoding.UTF8); } /// @@ -295,8 +295,8 @@ namespace Umbraco.Core.Composing { try { - var filePath = GeTypesListFilePath(); - File.Delete(filePath); + var typesListFilePath = GetTypesListFilePath(); + File.Delete(typesListFilePath); } catch { @@ -312,11 +312,11 @@ namespace Umbraco.Core.Composing { var cache = new Dictionary, IEnumerable>(); - var filePath = GeTypesListFilePath(); - if (File.Exists(filePath) == false) + var typesListFilePath = GetTypesListFilePath(); + 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 +354,88 @@ namespace Umbraco.Core.Composing } // internal for tests - internal string GeTypesListFilePath() + internal static string GetTypesListFilePath() => GetFileBasePath() + ".list"; + + private static string GetTypesHashFilePath() => GetFileBasePath() + ".hash"; + + private static string GetFileBasePath() { - var filename = "umbraco-types." + NetworkHelper.FileSafeMachineName + ".list"; - return Path.Combine(_tempFolder, filename); + var localTempStorage = GlobalSettings.LocalTempStorageLocation; + if (_localTempStorage != localTempStorage) + { + string path; + switch (GlobalSettings.LocalTempStorageLocation) + { + case LocalTempStorage.AspNetTemp: + path = Path.Combine(HttpRuntime.CodegenDir, "UmbracoData", "umbraco-types"); + 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"); + break; + case LocalTempStorage.Default: + default: + var tempFolder = IOHelper.MapPath("~/App_Data/TEMP/TypesCache"); + path = Path.Combine(tempFolder, "umbraco-types." + NetworkHelper.FileSafeMachineName); + break; + } + + _fileBasePath = path; + _localTempStorage = localTempStorage; + } + + // ensure that the folder exists + var directory = Path.GetDirectoryName(_fileBasePath); + if (directory == null) + throw new InvalidOperationException($"Could not determine folder for path \"{_fileBasePath}\"."); + if (Directory.Exists(directory) == false) + Directory.CreateDirectory(directory); + + return _fileBasePath; } - private string GetTypesHashFilePath() + 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)) + var typesListFilePath = GetTypesListFilePath(); + 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 +465,13 @@ 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); + var typesListFilePath = GetTypesListFilePath(); + if (File.Exists(typesListFilePath)) + File.Delete(typesListFilePath); - path = GetTypesHashFilePath(); - if (File.Exists(path)) - File.Delete(path); + var typesHashFilePath = GetTypesHashFilePath(); + if (File.Exists(typesHashFilePath)) + File.Delete(typesHashFilePath); _runtimeCache.ClearCacheItem(CacheKey); } @@ -589,7 +649,8 @@ namespace Umbraco.Core.Composing // else proceed, typeList = new TypeList(baseType, attributeType); - var scan = RequiresRescanning || File.Exists(GeTypesListFilePath()) == false; + var typesListFilePath = GetTypesListFilePath(); + 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 3b7fc8daad..060ecf5d17 100644 --- a/src/Umbraco.Core/Configuration/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/GlobalSettings.cs @@ -473,24 +473,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["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..1231ee7156 --- /dev/null +++ b/src/Umbraco.Core/Configuration/LocalTempStorage.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Core.Configuration +{ + internal enum LocalTempStorage + { + Unknown = 0, + Default, + AspNetTemp, + EnvironmentTemp + } +} 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/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/Constants-Applications.cs b/src/Umbraco.Core/Constants-Applications.cs index 014c4af450..4c859469fd 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. /// @@ -121,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/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/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/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/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/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/Migrations/Expressions/Insert/Expressions/InsertDataExpression.cs b/src/Umbraco.Core/Migrations/Expressions/Insert/Expressions/InsertDataExpression.cs index 7fbd7765e3..7efe457402 100644 --- a/src/Umbraco.Core/Migrations/Expressions/Insert/Expressions/InsertDataExpression.cs +++ b/src/Umbraco.Core/Migrations/Expressions/Insert/Expressions/InsertDataExpression.cs @@ -32,19 +32,25 @@ namespace Umbraco.Core.Migrations.Expressions.Insert.Expressions { foreach (var item in Rows) { - var cols = ""; - var vals = ""; + var cols = new StringBuilder(); + var vals = new StringBuilder(); + var first = true; foreach (var keyVal in item) { - cols += SqlSyntax.GetQuotedColumnName(keyVal.Key) + ","; - vals += GetQuotedValue(keyVal.Value) + ","; + if (first) + { + first = false; + } + else + { + cols.Append(","); + vals.Append(","); + } + cols.Append(SqlSyntax.GetQuotedColumnName(keyVal.Key)); + vals.Append(GetQuotedValue(keyVal.Value)); } - cols = cols.TrimEnd(','); - vals = vals.TrimEnd(','); - var sql = string.Format(SqlSyntax.InsertData, - SqlSyntax.GetQuotedTableName(TableName), - cols, vals); + var sql = string.Format(SqlSyntax.InsertData, SqlSyntax.GetQuotedTableName(TableName), cols, vals); stmts.Append(sql); AppendStatementSeparator(stmts); 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 9f4c45f0ad..b1b405bcf4 100644 --- a/src/Umbraco.Core/Migrations/MigrationBase_Extra.cs +++ b/src/Umbraco.Core/Migrations/MigrationBase_Extra.cs @@ -12,19 +12,41 @@ namespace Umbraco.Core.Migrations { // provides extra methods for migrations - //fixme - why do we have tableName and provide a table type which we can just extract the table name from? + protected void AddColumn(string columnName) + { + var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); + AddColumn(table, table.Name, columnName); + } + protected void AddColumn(string tableName, string columnName) + { + var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); + AddColumn(table, tableName, columnName); + } + + private void AddColumn(TableDefinition table, string tableName, string columnName) { if (ColumnExists(tableName, columnName)) return; - var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); var column = table.Columns.First(x => x.Name == columnName); var createSql = SqlSyntax.Format(column); - Execute.Sql(string.Format(SqlSyntax.AddColumn, SqlSyntax.GetQuotedTableName(table.Name), createSql)).Do(); + Execute.Sql(string.Format(SqlSyntax.AddColumn, SqlSyntax.GetQuotedTableName(tableName), createSql)).Do(); + } + + protected void AddColumn(string columnName, out IEnumerable sqls) + { + var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); + AddColumn(table, table.Name, columnName, out sqls); } protected void AddColumn(string tableName, string columnName, out IEnumerable sqls) + { + var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); + AddColumn(table, tableName, columnName, out sqls); + } + + private void AddColumn(TableDefinition table, string tableName, string columnName, out IEnumerable sqls) { if (ColumnExists(tableName, columnName)) { @@ -32,7 +54,6 @@ namespace Umbraco.Core.Migrations return; } - var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); var column = table.Columns.First(x => x.Name == columnName); var createSql = SqlSyntax.Format(column, SqlSyntax.GetQuotedTableName(tableName), out sqls); Execute.Sql(string.Format(SqlSyntax.AddColumn, SqlSyntax.GetQuotedTableName(tableName), createSql)).Do(); diff --git a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs index 03e52e6e97..fb0b3fc670 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,23 +38,25 @@ 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 if (currentVersion > UmbracoVersion.SemanticVersion) - throw new InvalidOperationException($"Version {currentVersion} cannot be upgraded to {UmbracoVersion.SemanticVersion}."); + throw new InvalidOperationException($"Version {currentVersion} cannot be downgraded to {UmbracoVersion.SemanticVersion}."); switch (currentVersion.Major) { case 7: + // upgrading from version 7 return "{orig-" + currentVersion + "}"; case 8: // fixme remove when releasing - // this is very temp and for my own website - zpqrtbnk + // upgrading from version 8 + // should never happen, this is very temp and for my own website - zpqrtbnk return "{04F54303-3055-4700-8F76-35A37F232FF5}"; // right before the variants migration default: - throw new InvalidOperationException($"Version {currentVersion} should have an upgrade state in the key-value table."); + throw new InvalidOperationException($"Version {currentVersion} is not supported by the migration plan."); } } @@ -60,12 +64,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, "{7F0BF916-F64E-4B25-864A-170D6E6B68E5}"); - // UPGRADE FROM 7 // // when 8.0.0 is released, on the first upgrade, the state is automatically @@ -74,46 +72,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("{9E98CF10-3AE9-437B-AF54-8697D251A541}") - .Chain("{7F0BF916-F64E-4B25-864A-170D6E6B68E5}"); + 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}"); @@ -130,8 +120,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}"); @@ -158,6 +146,28 @@ namespace Umbraco.Core.Migrations.Upgrade Chain("{CC1B1201-1328-443C-954A-E0BBB8CCC1B5}"); Chain("{CA7DB949-3EF4-403D-8464-F9BA36A52E87}"); Chain("{7F0BF916-F64E-4B25-864A-170D6E6B68E5}"); + + // 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}"); + Chain("FIXGUID NEW FINAL"); + + // FINAL STATE - MUST MATCH LAST ONE 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 65db0cdd2d..eb39f37112 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 @@ -34,7 +34,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 Execute.Sql(Sql() diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/PropertyEditorsMigration.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/PropertyEditorsMigration.cs index cd4e702ea9..ee439088be 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/PropertyEditorsMigration.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/PropertyEditorsMigration.cs @@ -17,19 +17,22 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0 RenameDataType(Constants.PropertyEditors.Aliases.MemberPicker + "2", Constants.PropertyEditors.Aliases.MemberPicker); RenameDataType(Constants.PropertyEditors.Aliases.MultiNodeTreePicker + "2", Constants.PropertyEditors.Aliases.MultiNodeTreePicker); RenameDataType(Constants.PropertyEditors.Aliases.RelatedLinks + "2", Constants.PropertyEditors.Aliases.RelatedLinks); - RenameDataType("Umbraco.TextboxMultiple", Constants.PropertyEditors.Aliases.TextArea); - RenameDataType("Umbraco.Textbox", Constants.PropertyEditors.Aliases.TextBox); + RenameDataType("Umbraco.TextboxMultiple", Constants.PropertyEditors.Aliases.TextArea, false); + RenameDataType("Umbraco.Textbox", Constants.PropertyEditors.Aliases.TextBox, false); } - private void RenameDataType(string fromAlias, string toAlias) + private void RenameDataType(string fromAlias, string toAlias, bool checkCollision = true) { - var oldCount = Database.ExecuteScalar(Sql() - .SelectCount() - .From() - .Where(x => x.EditorAlias == toAlias)); + if (checkCollision) + { + var oldCount = Database.ExecuteScalar(Sql() + .SelectCount() + .From() + .Where(x => x.EditorAlias == toAlias)); - if (oldCount > 0) - throw new InvalidOperationException($"Cannot rename datatype alias \"{fromAlias}\" to \"{toAlias}\" because the target alias is already used."); + if (oldCount > 0) + throw new InvalidOperationException($"Cannot rename datatype alias \"{fromAlias}\" to \"{toAlias}\" because the target alias is already used."); + } Database.Execute(Sql() .Update(u => u.Set(x => x.EditorAlias, toAlias)) 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 78d6bf1085..174404b1b9 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/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/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/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/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/Identity/BackOfficeIdentityUser.cs b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs index 3de0d11de1..bd4905729d 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; } /// @@ -382,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/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/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/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/Models/PropertyCollection.cs b/src/Umbraco.Core/Models/PropertyCollection.cs index 9c51ddbe79..3d02c13e6c 100644 --- a/src/Umbraco.Core/Models/PropertyCollection.cs +++ b/src/Umbraco.Core/Models/PropertyCollection.cs @@ -158,7 +158,7 @@ namespace Umbraco.Core.Models public bool TryGetValue(string propertyTypeAlias, out Property property) { - property = this[propertyTypeAlias]; + property = this.FirstOrDefault(x => x.Alias.InvariantEquals(propertyTypeAlias)); return property != null; } 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/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 c2940d9299..a245f33dd8 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"; @@ -71,6 +72,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/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/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..340db50767 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")] // FIXME CANNOT UPGRADE??? + [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/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/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/Mappers/AuditEntryMapper.cs b/src/Umbraco.Core/Persistence/Mappers/AuditEntryMapper.cs new file mode 100644 index 0000000000..28c7c1eeec --- /dev/null +++ b/src/Umbraco.Core/Persistence/Mappers/AuditEntryMapper.cs @@ -0,0 +1,31 @@ +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; + + 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/AuditItemMapper.cs b/src/Umbraco.Core/Persistence/Mappers/AuditItemMapper.cs index a07520730c..ad1964ee07 100644 --- a/src/Umbraco.Core/Persistence/Mappers/AuditItemMapper.cs +++ b/src/Umbraco.Core/Persistence/Mappers/AuditItemMapper.cs @@ -5,6 +5,7 @@ using Umbraco.Core.Persistence.Dtos; namespace Umbraco.Core.Persistence.Mappers { [MapperFor(typeof(AuditItem))] + [MapperFor(typeof(IAuditItem))] public sealed class AuditItemMapper : BaseMapper { private static readonly ConcurrentDictionary PropertyInfoCacheInstance = new ConcurrentDictionary(); @@ -13,11 +14,11 @@ namespace Umbraco.Core.Persistence.Mappers protected override void BuildMap() { - CacheMap(src => src.Id, dto => dto.Id); - CacheMap(src => src.Comment, dto => dto.Comment); - CacheMap(src => src.AuditType, dto => dto.Header); - CacheMap(src => src.UserId, dto => dto.UserId); + 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/BaseMapper.cs b/src/Umbraco.Core/Persistence/Mappers/BaseMapper.cs index e2f82072b2..794daae4ff 100644 --- a/src/Umbraco.Core/Persistence/Mappers/BaseMapper.cs +++ b/src/Umbraco.Core/Persistence/Mappers/BaseMapper.cs @@ -26,9 +26,7 @@ namespace Umbraco.Core.Persistence.Mappers internal string Map(ISqlSyntaxProvider sqlSyntax, string propertyName, bool throws = false) { - DtoMapModel dtoTypeProperty; - - if (PropertyInfoCache.TryGetValue(propertyName, out dtoTypeProperty)) + if (PropertyInfoCache.TryGetValue(propertyName, out var dtoTypeProperty)) return GetColumnName(sqlSyntax, dtoTypeProperty.Type, dtoTypeProperty.PropertyInfo); if (throws) diff --git a/src/Umbraco.Core/Persistence/Mappers/ConsentMapper.cs b/src/Umbraco.Core/Persistence/Mappers/ConsentMapper.cs new file mode 100644 index 0000000000..063197d1a2 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Mappers/ConsentMapper.cs @@ -0,0 +1,30 @@ +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; + + 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/Persistence/Mappers/MapperCollectionBuilder.cs b/src/Umbraco.Core/Persistence/Mappers/MapperCollectionBuilder.cs index 6c79254a0f..82085027ab 100644 --- a/src/Umbraco.Core/Persistence/Mappers/MapperCollectionBuilder.cs +++ b/src/Umbraco.Core/Persistence/Mappers/MapperCollectionBuilder.cs @@ -54,6 +54,8 @@ namespace Umbraco.Core.Persistence.Mappers Add(); Add(); Add(); + Add(); + Add(); return this; } } 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 68cd81fa51..3208cae41e 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", $"{Constants.DatabaseSchema.Tables.DataType}.propertyEditorAlias", $"{Constants.DatabaseSchema.Tables.DataType}.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/Persistence/UmbracoDatabase.cs b/src/Umbraco.Core/Persistence/UmbracoDatabase.cs index 28b920a269..9f591bac8c 100644 --- a/src/Umbraco.Core/Persistence/UmbracoDatabase.cs +++ b/src/Umbraco.Core/Persistence/UmbracoDatabase.cs @@ -1,15 +1,12 @@ using System; using System.Data; using System.Data.Common; -using System.Data.SqlClient; using System.Linq; using System.Text; using NPoco; using StackExchange.Profiling; using Umbraco.Core.Logging; using Umbraco.Core.Persistence.FaultHandling; -using Umbraco.Core.Persistence.Querying; -using Umbraco.Core.Persistence.SqlSyntax; namespace Umbraco.Core.Persistence { 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/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 69% rename from src/Umbraco.Web/PropertyEditors/ValueListConfiguration.cs rename to src/Umbraco.Core/PropertyEditors/ValueListConfiguration.cs index 30d2aa77cd..23ea4547df 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueListConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueListConfiguration.cs @@ -1,14 +1,14 @@ 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")] + [ConfigurationField("items", "Configure", "multivalues", Description = "Add and remove values for the list.")] public List Items { get; set; } = new List(); public class ValueListItem @@ -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/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/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/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.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/Security/AuthenticationExtensions.cs b/src/Umbraco.Core/Security/AuthenticationExtensions.cs index 294b8bbd73..17bb32675b 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)) + { + 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..cec1ee6bcb 100644 --- a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs +++ b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNet.Identity; @@ -24,9 +23,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 +36,13 @@ 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 - { - - } + { } } diff --git a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs index 9b0b483421..cb0ce2b147 100644 --- a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs @@ -1,21 +1,62 @@ using System; +using System.Collections.Concurrent; 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 Umbraco.Core.Composing; using Umbraco.Core.Configuration; +using Umbraco.Core.Services; namespace Umbraco.Core.Security { public class BackOfficeCookieAuthenticationProvider : CookieAuthenticationProvider { + // fixme inject + private IUserService UserService => Current.Services.UserService; + private IRuntimeState RuntimeState => Current.RuntimeState; + + public override void ResponseSignIn(CookieResponseSignInContext context) + { + 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 + + var session = RuntimeState.Level == RuntimeLevel.Run + ? 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?.OwinContext?.Authentication?.User?.Identity != null) + { + var claimsIdentity = context.OwinContext.Authentication.User.Identity as ClaimsIdentity; + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out var guidSession)) + { + 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 +75,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 (RuntimeState.Level == RuntimeLevel.Run) + 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/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..b320bd16d7 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,16 @@ 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 + { + var objectType = GetNodeObjectTypeGuid(umbracoObjectType); + sql = sql.Where(x => x.NodeObjectType == objectType || x.NodeObjectType == Constants.ObjectTypes.IdReservation); // fixme TEST the OR here! + } + + val = scope.Database.ExecuteScalar(sql); scope.Complete(); } @@ -89,8 +99,16 @@ 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 + { + var objectType = GetNodeObjectTypeGuid(umbracoObjectType); + sql = sql.Where(x => x.NodeObjectType == objectType || x.NodeObjectType == Constants.ObjectTypes.IdReservation); // fixme TEST the OR here! + } + + val = scope.Database.ExecuteScalar(sql); scope.Complete(); } @@ -104,7 +122,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..272d550a43 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 && performingUserId != Constants.Security.SuperId) 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/KeyValueService.cs b/src/Umbraco.Core/Services/Implement/KeyValueService.cs index 9b91325eab..cb1c423535 100644 --- a/src/Umbraco.Core/Services/Implement/KeyValueService.cs +++ b/src/Umbraco.Core/Services/Implement/KeyValueService.cs @@ -35,8 +35,9 @@ namespace Umbraco.Core.Services.Implement private void Initialize() { - // all this cannot be achieved via default migrations since it needs to run - // before any migration, in order to figure out migrations, ironically we are using a custom migration to do this + // all this cannot be achieved via an UmbracoPlan migration since it needs to + // run before any migration, in order to figure out the current plan's state. + // (does not prevent us from using a migration to do it, though) using (var scope = _scopeProvider.CreateScope()) { @@ -56,13 +57,13 @@ namespace Umbraco.Core.Services.Implement } /// - /// A custom migration that executes standalone during the Initialize phase of this service + /// A custom migration that executes standalone during the Initialize phase of this service. /// private class InitializeMigration : MigrationBase { - public InitializeMigration(IMigrationContext context) : base(context) - { - } + public InitializeMigration(IMigrationContext context) + : base(context) + { } public override void Migrate() { @@ -79,9 +80,9 @@ namespace Umbraco.Core.Services.Implement Delete.Column("nid").FromTable(Constants.DatabaseSchema.Tables.Lock).Do(); // complete the primary key Alter.Table(Constants.DatabaseSchema.Tables.Lock).AlterColumn("id").AsInt32().NotNullable().PrimaryKey("PK_umbracoLock").Do(); - + // insert the key-value lock - Insert.IntoTable(Constants.DatabaseSchema.Tables.Lock).Row(new {id = Constants.Locks.KeyValues, name = "KeyValues", value = 1}).Do(); + Insert.IntoTable(Constants.DatabaseSchema.Tables.Lock).Row(new {id = Constants.Locks.KeyValues, name = "KeyValues", value = 1}).Do(); // create the key-value table if it's not there if (TableExists(Constants.DatabaseSchema.Tables.KeyValue) == false) 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/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/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..2bdad031de 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; @@ -41,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, @@ -63,6 +65,8 @@ namespace Umbraco.Core.Sync protected IScopeProvider ScopeProvider { get; } protected Sql Sql() => _sqlContext.Sql(); + + private string DistCacheFilePath => _distCacheFilePath.Value; #region Messenger @@ -82,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); @@ -92,7 +95,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 +186,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 +225,7 @@ namespace Umbraco.Core.Sync /// /// Synchronize the server (throttled). /// - protected void Sync() + protected internal void Sync() { lock (_locko) { @@ -484,11 +487,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 +503,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 +523,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/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/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/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/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 7089d164bc..2d8c7f2561 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 @@ -98,6 +98,7 @@ + @@ -140,10 +141,12 @@ + + @@ -182,7 +185,6 @@ - @@ -230,6 +232,7 @@ + @@ -237,6 +240,7 @@ + @@ -251,6 +255,7 @@ + @@ -272,6 +277,7 @@ + @@ -290,6 +296,7 @@ + @@ -312,13 +319,31 @@ + + + + + + + + + + + + + + + + + + @@ -331,9 +356,29 @@ + + + + + + + + + + + + + + + + + + + + @@ -352,6 +397,7 @@ + @@ -891,7 +937,6 @@ - @@ -1272,6 +1317,7 @@ + @@ -1279,6 +1325,7 @@ + @@ -1304,6 +1351,7 @@ + @@ -1312,6 +1360,7 @@ + diff --git a/src/Umbraco.Core/UriExtensions.cs b/src/Umbraco.Core/UriExtensions.cs index 6c37a43623..88180428c4 100644 --- a/src/Umbraco.Core/UriExtensions.cs +++ b/src/Umbraco.Core/UriExtensions.cs @@ -1,8 +1,10 @@ using System; using System.IO; using System.Linq; +using Umbraco.Core.Composing; using Umbraco.Core.Configuration; using Umbraco.Core.IO; +using Umbraco.Core.Logging; namespace Umbraco.Core { @@ -142,10 +144,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) + { + Current.Logger.Error(typeof(UriExtensions), "Failed to determine if request was client side", ex); + return false; + } } /// diff --git a/src/Umbraco.Tests.Benchmarks/BulkInsertBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/BulkInsertBenchmarks.cs index 62c837ec23..0505974304 100644 --- a/src/Umbraco.Tests.Benchmarks/BulkInsertBenchmarks.cs +++ b/src/Umbraco.Tests.Benchmarks/BulkInsertBenchmarks.cs @@ -17,31 +17,15 @@ using Umbraco.Core.Persistence.Mappers; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.SqlSyntax; using Umbraco.Core.Scoping; +using Umbraco.Tests.Benchmarks.Config; using Umbraco.Tests.TestHelpers; using ILogger = Umbraco.Core.Logging.ILogger; namespace Umbraco.Tests.Benchmarks { - [Config(typeof(Config))] + [QuickRunWithMemoryDiagnoserConfig] public class BulkInsertBenchmarks { - private class Config : ManualConfig - { - public Config() - { - Add(new MemoryDiagnoser()); - //Add(ExecutionValidator.FailOnError); - - //The 'quick and dirty' settings, so it runs a little quicker - // see benchmarkdotnet FAQ - Add(Job.Default - .WithLaunchCount(1) // benchmark process will be launched only once - .WithIterationTime(TimeInterval.FromMilliseconds(100)) // 100ms per iteration - .WithWarmupCount(3) // 3 warmup iteration - .WithTargetCount(3)); // 3 target iteration - } - } - private static byte[] _initDbBytes; // fixme - should run on LocalDb same as NPoco tests! @@ -71,7 +55,7 @@ namespace Umbraco.Tests.Benchmarks return f.CreateDatabase(); } - [Setup] + [GlobalSetup] public void Setup() { var logger = new DebugDiagnosticsLogger(); @@ -158,7 +142,7 @@ namespace Umbraco.Tests.Benchmarks return data; } - [Cleanup] + [GlobalCleanup] public void Cleanup() { _dbSqlCe.Dispose(); diff --git a/src/Umbraco.Tests.Benchmarks/ConcurrentDictionaryBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/ConcurrentDictionaryBenchmarks.cs new file mode 100644 index 0000000000..4e8476bb6d --- /dev/null +++ b/src/Umbraco.Tests.Benchmarks/ConcurrentDictionaryBenchmarks.cs @@ -0,0 +1,153 @@ +using BenchmarkDotNet.Attributes; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Umbraco.Core.Collections; + +namespace Umbraco.Tests.Benchmarks +{ + [MemoryDiagnoser] + public class ConcurrentDictionaryBenchmarks + { + private static readonly ConcurrentDictionary AssignableTypeCache = new ConcurrentDictionary(); + + private static object input = new Bar(); + + private static Type source = typeof(Bar); + + private static Type target = typeof(Foo); + + [Benchmark(Baseline = true)] + public bool GetCachedCanAssignFactory() + { + return AssignableTypeCache.GetOrAdd(new CompositeTypeTypeKey(source, target), k => + { + var ksource = k.Type1; + var ktarget = k.Type2; + + return ktarget.IsAssignableFrom(ksource) && typeof(IConvertible).IsAssignableFrom(ksource); + }); + } + + [Benchmark] + public bool GetCachedCanAssignNoFactory() + { + // This method is 10% faster + var key = new CompositeTypeTypeKey(source, target); + bool canConvert; + if (AssignableTypeCache.TryGetValue(key, out canConvert)) + { + return canConvert; + } + + // "is" is faster than "IsAssignableFrom" + if (input is IConvertible && target.IsAssignableFrom(source)) + { + return AssignableTypeCache[key] = true; + } + + return AssignableTypeCache[key] = false; + } + + private class Foo : IConvertible + { + public TypeCode GetTypeCode() + { + return TypeCode.Object; + } + + public bool ToBoolean(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public byte ToByte(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public char ToChar(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public DateTime ToDateTime(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public decimal ToDecimal(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public double ToDouble(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public short ToInt16(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public int ToInt32(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public long ToInt64(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public sbyte ToSByte(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public float ToSingle(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public string ToString(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public object ToType(Type conversionType, IFormatProvider provider) + { + if (conversionType == typeof(Foo)) + { + return new Foo(); + } + + throw new NotImplementedException(); + } + + public ushort ToUInt16(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public uint ToUInt32(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public ulong ToUInt64(IFormatProvider provider) + { + throw new NotImplementedException(); + } + } + + private class Bar : Foo + { + + } + } +} diff --git a/src/Umbraco.Tests.Benchmarks/Config/QuickRunConfigAttribute.cs b/src/Umbraco.Tests.Benchmarks/Config/QuickRunConfigAttribute.cs new file mode 100644 index 0000000000..f7d6b6bb72 --- /dev/null +++ b/src/Umbraco.Tests.Benchmarks/Config/QuickRunConfigAttribute.cs @@ -0,0 +1,33 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Horology; +using BenchmarkDotNet.Jobs; +using System; + +namespace Umbraco.Tests.Benchmarks.Config +{ + /// + /// Configures the benchmark to run with less warmup and a shorter iteration time than the standard benchmark. + /// + public class QuickRunConfigAttribute : Attribute, IConfigSource + { + /// + /// Initializes a new instance of the class. + /// + public QuickRunConfigAttribute() + { + Config = (ManualConfig) ManualConfig.CreateEmpty() + .With(Job.Default.WithLaunchCount(1) // benchmark process will be launched only once + .WithIterationTime(new TimeInterval(100, TimeUnit.Millisecond)) // 100ms per iteration + .WithWarmupCount(3) // 3 warmup iteration + .WithTargetCount(3)); // 3 target iteration + } + + /// + /// Gets the manual configuration. + /// + protected ManualConfig Config { get; } + + /// + IConfig IConfigSource.Config => Config; + } +} \ No newline at end of file diff --git a/src/Umbraco.Tests.Benchmarks/Config/QuickRunWithMemoryDiagnoserConfigAttribute.cs b/src/Umbraco.Tests.Benchmarks/Config/QuickRunWithMemoryDiagnoserConfigAttribute.cs new file mode 100644 index 0000000000..381efc9139 --- /dev/null +++ b/src/Umbraco.Tests.Benchmarks/Config/QuickRunWithMemoryDiagnoserConfigAttribute.cs @@ -0,0 +1,20 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; + +namespace Umbraco.Tests.Benchmarks.Config +{ + /// + /// Configures the benchmark to run with less warmup and a shorter iteration time than the standard benchmark, + /// and include memory usage diagnosis. + /// + public class QuickRunWithMemoryDiagnoserConfigAttribute : QuickRunConfigAttribute + { + /// + /// Initializes a new instance of the class. + /// + public QuickRunWithMemoryDiagnoserConfigAttribute() + { + Config.Add(new MemoryDiagnoser()); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Tests.Benchmarks/LinqCastBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/LinqCastBenchmarks.cs index 50ae745237..824b1beed0 100644 --- a/src/Umbraco.Tests.Benchmarks/LinqCastBenchmarks.cs +++ b/src/Umbraco.Tests.Benchmarks/LinqCastBenchmarks.cs @@ -9,17 +9,9 @@ namespace Umbraco.Tests.Benchmarks /// /// Want to check what is faster OfType or Cast when a enurable all has the same items /// - [Config(typeof(Config))] + [MemoryDiagnoser] public class LinqCastBenchmarks { - private class Config : ManualConfig - { - public Config() - { - Add(new MemoryDiagnoser()); - } - } - public LinqCastBenchmarks() { _array = new List(); diff --git a/src/Umbraco.Tests.Benchmarks/ModelToSqlExpressionHelperBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/ModelToSqlExpressionHelperBenchmarks.cs index e029eea473..3532eba6b2 100644 --- a/src/Umbraco.Tests.Benchmarks/ModelToSqlExpressionHelperBenchmarks.cs +++ b/src/Umbraco.Tests.Benchmarks/ModelToSqlExpressionHelperBenchmarks.cs @@ -1,7 +1,6 @@ using System; using System.Linq.Expressions; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; using Moq; using Umbraco.Core.Models; @@ -11,17 +10,9 @@ using Umbraco.Core.Persistence.SqlSyntax; namespace Umbraco.Tests.Benchmarks { - [Config(typeof(Config))] + [MemoryDiagnoser] public class ModelToSqlExpressionHelperBenchmarks { - private class Config : ManualConfig - { - public Config() - { - Add(new MemoryDiagnoser()); - } - } - public ModelToSqlExpressionHelperBenchmarks() { var contentMapper = new ContentMapper(); @@ -41,7 +32,7 @@ namespace Umbraco.Tests.Benchmarks for (int i = 0; i < 100; i++) { var a = i; - var b = i*10; + var b = i * 10; Expression> predicate = content => content.Path.StartsWith("-1") && content.Published && (content.ContentTypeId == a || content.ContentTypeId == b); @@ -68,6 +59,5 @@ namespace Umbraco.Tests.Benchmarks var result = modelToSqlExpressionHelper.Visit(_cachedExpression); } } - } } diff --git a/src/Umbraco.Tests.Benchmarks/Program.cs b/src/Umbraco.Tests.Benchmarks/Program.cs index 9687225a46..c9332e7fa3 100644 --- a/src/Umbraco.Tests.Benchmarks/Program.cs +++ b/src/Umbraco.Tests.Benchmarks/Program.cs @@ -6,19 +6,7 @@ namespace Umbraco.Tests.Benchmarks { public static void Main(string[] args) { - var switcher = new BenchmarkSwitcher(new[] - { - typeof(BulkInsertBenchmarks), - typeof(ModelToSqlExpressionHelperBenchmarks), - typeof(XmlBenchmarks), - typeof(LinqCastBenchmarks), - //typeof(DeepCloneBenchmarks), - typeof(XmlPublishedContentInitBenchmarks), - typeof(CtorInvokeBenchmarks), - typeof(SqlTemplatesBenchmark), - - }); - switcher.Run(args); + new BenchmarkSwitcher(typeof(Program).Assembly).Run(args); } } } diff --git a/src/Umbraco.Tests.Benchmarks/StringReplaceManyBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/StringReplaceManyBenchmarks.cs new file mode 100644 index 0000000000..796fe4f4b6 --- /dev/null +++ b/src/Umbraco.Tests.Benchmarks/StringReplaceManyBenchmarks.cs @@ -0,0 +1,138 @@ +using BenchmarkDotNet.Attributes; +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Tests.Benchmarks.Config; + +namespace Umbraco.Tests.Benchmarks +{ + [QuickRunWithMemoryDiagnoserConfig] + public class StringReplaceManyBenchmarks + { + /* + + short text, short replacement: + + Method | Mean | Error | StdDev | Scaled | ScaledSD | Gen 0 | Allocated | + --------------------------------------------- |---------:|----------:|----------:|-------:|---------:|-------:|----------:| + 'String.ReplaceMany w/chars - Aggregate' | 236.0 ns | 40.92 ns | 2.312 ns | 1.00 | 0.00 | 0.0461 | 200 B | + 'String.ReplaceMany w/chars - For Loop' | 166.7 ns | 70.51 ns | 3.984 ns | 0.71 | 0.01 | 0.0420 | 180 B | + 'String.ReplaceMany w/dictionary - Aggregate' | 606.5 ns | 342.94 ns | 19.377 ns | 2.57 | 0.07 | 0.0473 | 212 B | + 'String.ReplaceMany w/dictionary - For Each' | 571.8 ns | 232.33 ns | 13.127 ns | 2.42 | 0.05 | 0.0458 | 212 B | + + long text, short replacement: + + Method | Mean | Error | StdDev | Scaled | ScaledSD | Gen 0 | Allocated | + --------------------------------------------- |----------:|----------:|----------:|-------:|---------:|-------:|----------:| + 'String.ReplaceMany w/chars - Aggregate' | 5.771 us | 9.963 us | 0.5630 us | 1.00 | 0.00 | 1.6798 | 6.94 KB | + 'String.ReplaceMany w/chars - For Loop' | 4.962 us | 2.121 us | 0.1199 us | 0.87 | 0.08 | 1.6840 | 6.92 KB | + 'String.ReplaceMany w/dictionary - Aggregate' | 14.514 us | 8.189 us | 0.4627 us | 2.53 | 0.22 | 1.6447 | 6.96 KB | + 'String.ReplaceMany w/dictionary - For Each' | 15.445 us | 24.745 us | 1.3981 us | 2.69 | 0.30 | 1.5696 | 6.96 KB | + + short text, long replacements dictionary: + + Method | Mean | Error | StdDev | Scaled | ScaledSD | Gen 0 | Allocated | + --------------------------------------------- |-----------:|-----------:|----------:|-------:|---------:|-------:|----------:| + 'String.ReplaceMany w/chars - Aggregate' | 257.0 ns | 200.0 ns | 11.30 ns | 1.00 | 0.00 | 0.0452 | 200 B | + 'String.ReplaceMany w/chars - For Loop' | 182.4 ns | 221.0 ns | 12.49 ns | 0.71 | 0.05 | 0.0425 | 180 B | + 'String.ReplaceMany w/dictionary - Aggregate' | 7,273.8 ns | 2,747.1 ns | 155.22 ns | 28.34 | 1.12 | 0.0714 | 464 B | + 'String.ReplaceMany w/dictionary - For Each' | 6,981.0 ns | 5,500.7 ns | 310.80 ns | 27.20 | 1.38 | 0.0775 | 464 B | + + long text, long replacements dictionary: + + Method | Mean | Error | StdDev | Scaled | ScaledSD | Gen 0 | Allocated | + --------------------------------------------- |-----------:|-----------:|-----------:|-------:|---------:|-------:|----------:| + 'String.ReplaceMany w/chars - Aggregate' | 4.868 us | 3.420 us | 0.1932 us | 1.00 | 0.00 | 1.6816 | 6.94 KB | + 'String.ReplaceMany w/chars - For Loop' | 4.958 us | 2.633 us | 0.1487 us | 1.02 | 0.04 | 1.6791 | 6.92 KB | + 'String.ReplaceMany w/dictionary - Aggregate' | 181.309 us | 210.177 us | 11.8754 us | 37.29 | 2.32 | 5.3571 | 24.28 KB | + 'String.ReplaceMany w/dictionary - For Each' | 174.567 us | 113.733 us | 6.4262 us | 35.90 | 1.57 | 5.8594 | 24.28 KB | + + */ + + // don't use constants + // ReSharper disable ConvertToConstant.Local + + // input text for ReplaceMany + private static readonly string Text; + + // replaced chars for ReplaceMany with chars + private static readonly char[] ReplacedChars = { ',', '.', ':', '&', '#' }; + + // replacement char for ReplaceMany with chars + private static readonly char ReplacementChar = '*'; + + // replacements for ReplaceMany with dictionary + private static readonly IDictionary Replacements; + + // ReSharper restore ConvertToConstant.Local + + static StringReplaceManyBenchmarks() + { + // pick what you want to benchmark + + // short + Text = "1,2.3:4&5#6"; + + // long + //Text = "Sed ut perspiciatis unde omnis iste natus &error sit voluptatem accusantium doloremque l:audantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et &quasi architecto beatae vitae ::dicta sunt explicabo. Nemo enim ipsam volupta:tem quia voluptas sit aspernatur aut o&dit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciun&t. Neque porro quisquam est, qui dolorem: ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut e:nim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi co&&nsequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse: quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?"; + + // short + Replacements = new Dictionary + { + { ",", "*" }, + { ".", "*" }, + { ":", "*" }, + { "&", "*" }, + { "#", "*" }, + }; + + // long + //Replacements = new Dictionary(); + //for (var i = 2; i < 100; i++) + // Replacements[Convert.ToChar(i).ToString()] = "*"; + } + + // this is what v7 originally did + [Benchmark(Description = "String.ReplaceMany w/chars - Aggregate", Baseline = true)] + public string ReplaceManyAggregate() + { + var result = Text; + return ReplacedChars.Aggregate(result, (current, c) => current.Replace(c, ReplacementChar)); + } + + [Benchmark(Description = "String.ReplaceMany w/chars - For Loop")] + public string ReplaceManyForLoop() + { + var result = Text; + + // ReSharper disable once LoopCanBeConvertedToQuery + // ReSharper disable once ForCanBeConvertedToForeach + for (var i = 0; i < ReplacedChars.Length; i++) + { + result = result.Replace(ReplacedChars[i], ReplacementChar); + } + + return result; + } + + // this is what v7 originally did + [Benchmark(Description = "String.ReplaceMany w/dictionary - Aggregate")] + public string ReplaceManyDictionaryAggregate() + { + return Replacements.Aggregate(Text, (current, kvp) => current.Replace(kvp.Key, kvp.Value)); + } + + [Benchmark(Description = "String.ReplaceMany w/dictionary - For Each")] + public string ReplaceManyDictionaryForEach() + { + var result = Text; + // ReSharper disable once LoopCanBeConvertedToQuery + foreach (var item in Replacements) + { + result = result.Replace(item.Key, item.Value); + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Tests.Benchmarks/TryConvertToBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/TryConvertToBenchmarks.cs new file mode 100644 index 0000000000..57b47dc1d0 --- /dev/null +++ b/src/Umbraco.Tests.Benchmarks/TryConvertToBenchmarks.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using Umbraco.Core; + +namespace Umbraco.Tests.Benchmarks +{ + [MemoryDiagnoser] + public class TryConvertToBenchmarks + { + private static readonly List List = new List() { "hello", "world", "awesome" }; + private static readonly string Date = "Saturday 10, November 2012"; + + [Benchmark(Description = "List to IEnumerable")] + public IEnumerable TryConvertToEnumerable() + { + return List.TryConvertTo>().Result; + } + + [Benchmark(Description = "Int to Double")] + public double TryConvertToDouble() + { + return 1.TryConvertTo().Result; + } + + [Benchmark(Description = "Float to Decimal")] + public decimal TryConvertToDecimal() + { + return 1F.TryConvertTo().Result; + } + + [Benchmark(Description = "String to Boolean")] + public bool TryConvertToBoolean() + { + return "1".TryConvertTo().Result; + } + + [Benchmark(Description = "String to DateTime")] + public DateTime TryConvertToDateTime() + { + return Date.TryConvertTo().Result; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj b/src/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj index 5ce8d7d266..70474cdff0 100644 --- a/src/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj +++ b/src/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj @@ -14,6 +14,7 @@ + true AnyCPU @@ -40,35 +41,35 @@ Always - - ..\packages\BenchmarkDotNet.0.10.8\lib\net46\BenchmarkDotNet.dll + + ..\packages\BenchmarkDotNet.0.10.12\lib\net46\BenchmarkDotNet.dll - - ..\packages\BenchmarkDotNet.Core.0.10.8\lib\net46\BenchmarkDotNet.Core.dll + + ..\packages\BenchmarkDotNet.Core.0.10.12\lib\net46\BenchmarkDotNet.Core.dll - - ..\packages\BenchmarkDotNet.Diagnostics.Windows.0.10.8\lib\net46\BenchmarkDotNet.Diagnostics.Windows.dll + + ..\packages\BenchmarkDotNet.Diagnostics.Windows.0.9.9\lib\net45\BenchmarkDotNet.Diagnostics.Windows.dll - - ..\packages\BenchmarkDotNet.Toolchains.Roslyn.0.10.8\lib\net46\BenchmarkDotNet.Toolchains.Roslyn.dll + + ..\packages\BenchmarkDotNet.Toolchains.Roslyn.0.10.12\lib\net46\BenchmarkDotNet.Toolchains.Roslyn.dll ..\packages\Castle.Core.4.1.1\lib\net45\Castle.Core.dll - - ..\packages\Microsoft.CodeAnalysis.Common.2.3.0\lib\netstandard1.3\Microsoft.CodeAnalysis.dll - - - ..\packages\Microsoft.CodeAnalysis.CSharp.2.3.0\lib\netstandard1.3\Microsoft.CodeAnalysis.CSharp.dll - ..\packages\Microsoft.Diagnostics.Tracing.TraceEvent.1.0.41\lib\net40\Microsoft.Diagnostics.Tracing.TraceEvent.dll + + ..\packages\Microsoft.CodeAnalysis.Common.2.4.0\lib\netstandard1.3\Microsoft.CodeAnalysis.dll + + + ..\packages\Microsoft.CodeAnalysis.CSharp.2.4.0\lib\netstandard1.3\Microsoft.CodeAnalysis.CSharp.dll + ..\packages\Microsoft.DotNet.InternalAbstractions.1.0.0\lib\net451\Microsoft.DotNet.InternalAbstractions.dll - - ..\packages\Microsoft.DotNet.PlatformAbstractions.1.1.2\lib\net451\Microsoft.DotNet.PlatformAbstractions.dll + + ..\packages\Microsoft.DotNet.PlatformAbstractions.1.1.1\lib\net451\Microsoft.DotNet.PlatformAbstractions.dll ..\packages\Microsoft.Win32.Registry.4.3.0\lib\net46\Microsoft.Win32.Registry.dll @@ -82,6 +83,7 @@ ..\packages\System.AppContext.4.3.0\lib\net46\System.AppContext.dll + True ..\packages\System.Collections.Immutable.1.3.1\lib\portable-net45+win8+wp8+wpa81\System.Collections.Immutable.dll @@ -93,9 +95,8 @@ ..\packages\System.Console.4.3.0\lib\net46\System.Console.dll - - ..\packages\Microsoft.SqlServer.Compact.4.0.8876.1\lib\net40\System.Data.SqlServerCe.dll - True + + ..\packages\SqlServerCE.4.0.0.1\lib\System.Data.SqlServerCe.dll ..\packages\SqlServerCE.4.0.0.1\lib\System.Data.SqlServerCe.Entity.dll @@ -114,6 +115,7 @@ ..\packages\System.IO.FileSystem.Primitives.4.3.0\lib\net46\System.IO.FileSystem.Primitives.dll + True @@ -146,6 +148,10 @@ ..\packages\System.ValueTuple.4.4.0\lib\net461\System.ValueTuple.dll + + ..\packages\System.Security.Cryptography.X509Certificates.4.3.0\lib\net46\System.Security.Cryptography.X509Certificates.dll + True + @@ -167,18 +173,25 @@ + + + + + - - + + + Designer + @@ -194,10 +207,6 @@ Umbraco.Web - - - - @@ -217,11 +226,11 @@ xcopy "$(ProjectDir)"..\packages\SqlServerCE.4.0.0.1\x86\*.* "$(TargetDir)x86\" - - \ No newline at end of file + diff --git a/src/Umbraco.Tests.Benchmarks/XmlBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/XmlBenchmarks.cs index 99080ebab7..035c32900b 100644 --- a/src/Umbraco.Tests.Benchmarks/XmlBenchmarks.cs +++ b/src/Umbraco.Tests.Benchmarks/XmlBenchmarks.cs @@ -1,34 +1,14 @@ using System; using System.Xml; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Horology; -using BenchmarkDotNet.Jobs; +using Umbraco.Tests.Benchmarks.Config; namespace Umbraco.Tests.Benchmarks { - [Config(typeof(Config))] + [QuickRunWithMemoryDiagnoserConfig] public class XmlBenchmarks { - private class Config : ManualConfig - { - public Config() - { - Add(new MemoryDiagnoser()); - //Add(ExecutionValidator.FailOnError); - - //The 'quick and dirty' settings, so it runs a little quicker - // see benchmarkdotnet FAQ - Add(Job.Default - .WithLaunchCount(1) // benchmark process will be launched only once - .WithIterationTime(TimeInterval.FromMilliseconds(100)) // 100ms per iteration - .WithWarmupCount(3) // 3 warmup iteration - .WithTargetCount(3)); // 3 target iteration - } - } - - [Setup] + [GlobalSetup] public void Setup() { var templateId = 0; @@ -67,7 +47,7 @@ namespace Umbraco.Tests.Benchmarks _xml.LoadXml(xmlText); } - [Cleanup] + [GlobalCleanup] public void Cleanup() { _xml = null; @@ -87,7 +67,7 @@ namespace Umbraco.Tests.Benchmarks public void XmlWithNavigation() { var elt = _xml.DocumentElement; - var id = NavigateElementRoute(elt, new[] {"home", "sub1", "sub2"}); + var id = NavigateElementRoute(elt, new[] { "home", "sub1", "sub2" }); if (id <= 0) Console.WriteLine("ERR"); } diff --git a/src/Umbraco.Tests.Benchmarks/XmlPublishedContentInitBenchmarks.cs b/src/Umbraco.Tests.Benchmarks/XmlPublishedContentInitBenchmarks.cs index dbb32b18f7..751767f8e7 100644 --- a/src/Umbraco.Tests.Benchmarks/XmlPublishedContentInitBenchmarks.cs +++ b/src/Umbraco.Tests.Benchmarks/XmlPublishedContentInitBenchmarks.cs @@ -3,10 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Xml; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Horology; -using BenchmarkDotNet.Jobs; using Moq; using Umbraco.Core; using Umbraco.Core.Logging; @@ -14,29 +10,14 @@ using Umbraco.Core.Models; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; +using Umbraco.Tests.Benchmarks.Config; using Umbraco.Web.PublishedCache.XmlPublishedCache; namespace Umbraco.Tests.Benchmarks { - [Config(typeof(Config))] + [QuickRunWithMemoryDiagnoserConfig] public class XmlPublishedContentInitBenchmarks { - private class Config : ManualConfig - { - public Config() - { - Add(new MemoryDiagnoser()); - - //The 'quick and dirty' settings, so it runs a little quicker - // see benchmarkdotnet FAQ - Add(Job.Default - .WithLaunchCount(1) // benchmark process will be launched only once - .WithIterationTime(TimeInterval.FromMilliseconds(100)) // 100ms per iteration - .WithWarmupCount(3) // 3 warmup iteration - .WithTargetCount(3)); // 3 target iteration - } - } - public XmlPublishedContentInitBenchmarks() { _xml10 = Build(10); diff --git a/src/Umbraco.Tests.Benchmarks/packages.config b/src/Umbraco.Tests.Benchmarks/packages.config index 8c7c2d2d56..f8d7dec738 100644 --- a/src/Umbraco.Tests.Benchmarks/packages.config +++ b/src/Umbraco.Tests.Benchmarks/packages.config @@ -1,62 +1,63 @@  - - + + - + - - + + - - + + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Umbraco.Tests/Cache/CacheRefresherComponentTests.cs b/src/Umbraco.Tests/Cache/CacheRefresherComponentTests.cs index ee3e13e2cb..4399380f92 100644 --- a/src/Umbraco.Tests/Cache/CacheRefresherComponentTests.cs +++ b/src/Umbraco.Tests/Cache/CacheRefresherComponentTests.cs @@ -44,7 +44,7 @@ namespace Umbraco.Tests.Cache new EventDefinition>(null, serviceContext.UserService, new SaveEventArgs(Enumerable.Empty())), new EventDefinition>(null, serviceContext.UserService, new DeleteEventArgs(Enumerable.Empty())), - new EventDefinition>(null, serviceContext.UserService, new SaveEventArgs(Enumerable.Empty())), + new EventDefinition>(null, serviceContext.UserService, new SaveEventArgs(Enumerable.Empty())), new EventDefinition>(null, serviceContext.UserService, new DeleteEventArgs(Enumerable.Empty())), new EventDefinition>(null, serviceContext.LocalizationService, new SaveEventArgs(Enumerable.Empty())), diff --git a/src/Umbraco.Tests/Composing/TypeLoaderTests.cs b/src/Umbraco.Tests/Composing/TypeLoaderTests.cs index 0ff6bc66a9..e2af507871 100644 --- a/src/Umbraco.Tests/Composing/TypeLoaderTests.cs +++ b/src/Umbraco.Tests/Composing/TypeLoaderTests.cs @@ -146,7 +146,7 @@ namespace Umbraco.Tests.Composing [Test] public void Detect_Legacy_Plugin_File_List() { - var filePath = _typeLoader.GeTypesListFilePath(); + var filePath = TypeLoader.GetTypesListFilePath(); var fileDir = Path.GetDirectoryName(filePath); Directory.CreateDirectory(fileDir); @@ -257,7 +257,7 @@ AnotherContentFinder public void Resolves_Assigned_Mappers() { var foundTypes1 = _typeLoader.GetAssignedMapperTypes(); - Assert.AreEqual(28, foundTypes1.Count()); + Assert.AreEqual(30, foundTypes1.Count()); } [Test] @@ -285,7 +285,7 @@ AnotherContentFinder public void GetDataEditors() { var types = _typeLoader.GetDataEditors(); - Assert.AreEqual(41, types.Count()); + Assert.AreEqual(42, types.Count()); } [Test] diff --git a/src/Umbraco.Tests/CoreThings/ObjectExtensionsTests.cs b/src/Umbraco.Tests/CoreThings/ObjectExtensionsTests.cs index 5fdf4529ce..486d35540c 100644 --- a/src/Umbraco.Tests/CoreThings/ObjectExtensionsTests.cs +++ b/src/Umbraco.Tests/CoreThings/ObjectExtensionsTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Web.UI.WebControls; using NUnit.Framework; using Umbraco.Core; +using Umbraco.Core.PropertyEditors; namespace Umbraco.Tests.CoreThings { @@ -165,7 +166,7 @@ namespace Umbraco.Tests.CoreThings Assert.AreEqual("Hello world", result.Result); } - [Test] + [Test] public virtual void CanConvertObjectToSameObject() { var obj = new MyTestObject(); @@ -174,6 +175,141 @@ namespace Umbraco.Tests.CoreThings Assert.AreEqual(obj, result.Result); } + [Test] + public void ConvertToIntegerTest() + { + var conv = "100".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100, conv.Result); + + conv = "100.000".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100, conv.Result); + + conv = "100,000".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100, conv.Result); + + // oops + conv = "100.001".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100, conv.Result); + + conv = 100m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100, conv.Result); + + conv = 100.000m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100, conv.Result); + + // oops + conv = 100.001m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100, conv.Result); + } + + [Test] + public void ConvertToDecimalTest() + { + var conv = "100".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = "100.000".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = "100,000".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = "100.001".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100.001m, conv.Result); + + conv = 100m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = 100.000m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = 100.001m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100.001m, conv.Result); + + conv = 100.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + } + + [Test] + public void ConvertToNullableDecimalTest() + { + var conv = "100".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = "100.000".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = "100,000".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = "100.001".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100.001m, conv.Result); + + conv = 100m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = 100.000m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + + conv = 100.001m.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100.001m, conv.Result); + + conv = 100.TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(100m, conv.Result); + } + + [Test] + public void ConvertToDateTimeTest() + { + var conv = "2016-06-07".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(new DateTime(2016, 6, 7), conv.Result); + } + + [Test] + public void ConvertToNullableDateTimeTest() + { + var conv = "2016-06-07".TryConvertTo(); + Assert.IsTrue(conv); + Assert.AreEqual(new DateTime(2016, 6, 7), conv.Result); + } + + [Test] + public void Value_Editor_Can_Convert_Decimal_To_Decimal_Clr_Type() + { + var valueEditor = new DataValueEditor + { + ValueType = ValueTypes.Decimal + }; + + var result = valueEditor.TryConvertValueToCrlType(12.34d); + Assert.IsTrue(result.Success); + Assert.AreEqual(12.34d, result.Result); + } + private class MyTestObject { public override string ToString() diff --git a/src/Umbraco.Tests/CoreThings/UdiTests.cs b/src/Umbraco.Tests/CoreThings/UdiTests.cs index 64b24615c1..63cfcb093d 100644 --- a/src/Umbraco.Tests/CoreThings/UdiTests.cs +++ b/src/Umbraco.Tests/CoreThings/UdiTests.cs @@ -25,6 +25,8 @@ namespace Umbraco.Tests.CoreThings var container = new Mock(); container.Setup(x => x.GetInstance(typeof (TypeLoader))).Returns(new TypeLoader(NullCacheProvider.Instance, new ProfilingLogger(Mock.Of(), Mock.Of()))); Current.Container = container.Object; + + Udi.ResetUdiTypes(); } [TearDown] 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(); } } } diff --git a/src/Umbraco.Tests/Models/Mapping/ContentTypeModelMappingTests.cs b/src/Umbraco.Tests/Models/Mapping/ContentTypeModelMappingTests.cs index 39baa2000e..cd147c8b5d 100644 --- a/src/Umbraco.Tests/Models/Mapping/ContentTypeModelMappingTests.cs +++ b/src/Umbraco.Tests/Models/Mapping/ContentTypeModelMappingTests.cs @@ -133,6 +133,7 @@ namespace Umbraco.Tests.Models.Mapping Assert.AreEqual(propTypes.ElementAt(j).DataTypeId, result.PropertyTypes.ElementAt(j).DataTypeId); Assert.AreEqual(propTypes.ElementAt(j).MemberCanViewProperty, result.MemberCanViewProperty(result.PropertyTypes.ElementAt(j).Alias)); Assert.AreEqual(propTypes.ElementAt(j).MemberCanEditProperty, result.MemberCanEditProperty(result.PropertyTypes.ElementAt(j).Alias)); + Assert.AreEqual(propTypes.ElementAt(j).IsSensitiveData, result.IsSensitiveProperty(result.PropertyTypes.ElementAt(j).Alias)); } } @@ -336,7 +337,7 @@ namespace Umbraco.Tests.Models.Mapping // setup the mocks to return the data we want to test against... var memberType = MockedContentTypes.CreateSimpleMemberType(); - memberType.MemberTypePropertyTypes[memberType.PropertyTypes.Last().Alias] = new MemberTypePropertyProfileAccess(true, true); + memberType.MemberTypePropertyTypes[memberType.PropertyTypes.Last().Alias] = new MemberTypePropertyProfileAccess(true, true, true); MockedContentTypes.EnsureAllIds(memberType, 8888); @@ -470,18 +471,18 @@ namespace Umbraco.Tests.Models.Mapping //TODO: Now we need to assert all of the more complicated parts - Assert.AreEqual(contentType.PropertyGroups.Count(), result.Groups.Count()); - for (var i = 0; i < contentType.PropertyGroups.Count(); i++) + Assert.AreEqual(contentType.PropertyGroups.Count, result.Groups.Count()); + for (var i = 0; i < contentType.PropertyGroups.Count; i++) { - Assert.AreEqual(contentType.PropertyGroups.ElementAt(i).Id, result.Groups.ElementAt(i).Id); - Assert.AreEqual(contentType.PropertyGroups.ElementAt(i).Name, result.Groups.ElementAt(i).Name); - var propTypes = contentType.PropertyGroups.ElementAt(i).PropertyTypes; + Assert.AreEqual(contentType.PropertyGroups[i].Id, result.Groups.ElementAt(i).Id); + Assert.AreEqual(contentType.PropertyGroups[i].Name, result.Groups.ElementAt(i).Name); + var propTypes = contentType.PropertyGroups[i].PropertyTypes; - Assert.AreEqual(propTypes.Count(), result.Groups.ElementAt(i).Properties.Count()); - for (var j = 0; j < propTypes.Count(); j++) + Assert.AreEqual(propTypes.Count, result.Groups.ElementAt(i).Properties.Count()); + for (var j = 0; j < propTypes.Count; j++) { - Assert.AreEqual(propTypes.ElementAt(j).Id, result.Groups.ElementAt(i).Properties.ElementAt(j).Id); - Assert.AreEqual(propTypes.ElementAt(j).DataTypeId, result.Groups.ElementAt(i).Properties.ElementAt(j).DataTypeId); + Assert.AreEqual(propTypes[j].Id, result.Groups.ElementAt(i).Properties.ElementAt(j).Id); + Assert.AreEqual(propTypes[j].DataTypeId, result.Groups.ElementAt(i).Properties.ElementAt(j).DataTypeId); } } @@ -516,6 +517,7 @@ namespace Umbraco.Tests.Models.Mapping { MemberCanEditProperty = true, MemberCanViewProperty = true, + IsSensitiveData = true, Id = 33, SortOrder = 1, Alias = "prop1", @@ -533,6 +535,7 @@ namespace Umbraco.Tests.Models.Mapping { MemberCanViewProperty = false, MemberCanEditProperty = false, + IsSensitiveData = false, Id = 34, SortOrder = 2, Alias = "prop2", @@ -897,6 +900,7 @@ namespace Umbraco.Tests.Models.Mapping Label = "Prop 1", MemberCanViewProperty = true, MemberCanEditProperty = true, + IsSensitiveData = true, Validation = new PropertyTypeValidation() { Mandatory = true, @@ -916,6 +920,7 @@ namespace Umbraco.Tests.Models.Mapping Assert.AreEqual(basic.Validation, result.Validation); Assert.AreEqual(basic.MemberCanViewProperty, result.MemberCanViewProperty); Assert.AreEqual(basic.MemberCanEditProperty, result.MemberCanEditProperty); + Assert.AreEqual(basic.IsSensitiveData, result.IsSensitiveData); } [Test] @@ -982,6 +987,7 @@ namespace Umbraco.Tests.Models.Mapping { MemberCanEditProperty = true, MemberCanViewProperty = true, + IsSensitiveData = true, Alias = "property1", Description = "this is property 1", Inherited = false, diff --git a/src/Umbraco.Tests/Models/Mapping/ContentWebModelMappingTests.cs b/src/Umbraco.Tests/Models/Mapping/ContentWebModelMappingTests.cs index 8bde14678d..da080ac2ce 100644 --- a/src/Umbraco.Tests/Models/Mapping/ContentWebModelMappingTests.cs +++ b/src/Umbraco.Tests/Models/Mapping/ContentWebModelMappingTests.cs @@ -114,13 +114,29 @@ namespace Umbraco.Tests.Models.Mapping var content = MockedContent.CreateSimpleContent(contentType); FixUsers(content); - //need ids for tabs + // need ids for tabs var id = 1; foreach (var g in content.PropertyGroups) - { - g.Id = id; - id++; - } + g.Id = id++; + + var result = Mapper.Map(content); + + AssertBasics(result, content); + + foreach (var p in content.Properties) + AssertDisplayProperty(result, p); + + Assert.AreEqual(content.PropertyGroups.Count(), result.Tabs.Count()); + Assert.IsTrue(result.Tabs.First().IsActive); + Assert.IsTrue(result.Tabs.Except(new[] {result.Tabs.First()}).All(x => x.IsActive == false)); + } + + [Test] + public void To_Display_Model_No_Tabs() + { + var contentType = MockedContentTypes.CreateSimpleContentType(); + contentType.PropertyGroups.Clear(); + var content = new Content("Home", -1, contentType) { Level = 1, SortOrder = 1, CreatorId = 0, WriterId = 0 }; var result = Mapper.Map(content); @@ -129,12 +145,9 @@ namespace Umbraco.Tests.Models.Mapping { AssertDisplayProperty(result, p); } - Assert.AreEqual(content.PropertyGroups.Count(), result.Tabs.Count() - 1); - Assert.IsTrue(result.Tabs.Any(x => x.Label == Current.Services.TextService.Localize("general/properties"))); - Assert.IsTrue(result.Tabs.First().IsActive); - Assert.IsTrue(result.Tabs.Except(new[] {result.Tabs.First()}).All(x => x.IsActive == false)); + Assert.AreEqual(content.PropertyGroups.Count(), result.Tabs.Count()); } - + [Test] public void To_Display_Model_With_Non_Grouped_Properties() { @@ -205,9 +218,19 @@ namespace Umbraco.Tests.Models.Mapping where TPersisted : IContentBase { Assert.AreEqual(content.Id, result.Id); - Assert.IsNotNull(result.Owner); - Assert.AreEqual(Constants.Security.SuperId, result.Owner.UserId); - Assert.AreEqual("Administrator", result.Owner.Name); + + var ownerId = content.CreatorId; + if (ownerId != 0) + { + Assert.IsNotNull(result.Owner); + Assert.AreEqual(Constants.Security.SuperId, result.Owner.UserId); + Assert.AreEqual("Administrator", result.Owner.Name); + } + else + { + Assert.IsNull(result.Owner); // because, 0 is no user + } + Assert.AreEqual(content.ParentId, result.ParentId); Assert.AreEqual(content.UpdateDate, result.UpdateDate); Assert.AreEqual(content.CreateDate, result.CreateDate); diff --git a/src/Umbraco.Tests/Persistence/Repositories/AuditRepositoryTest.cs b/src/Umbraco.Tests/Persistence/Repositories/AuditRepositoryTest.cs index decdb8fe48..3bb4a42ba4 100644 --- a/src/Umbraco.Tests/Persistence/Repositories/AuditRepositoryTest.cs +++ b/src/Umbraco.Tests/Persistence/Repositories/AuditRepositoryTest.cs @@ -1,7 +1,9 @@ using System.Linq; using NUnit.Framework; 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.Persistence.Repositories.Implement; using Umbraco.Core.Scoping; @@ -29,5 +31,95 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.That(dtos.First().Comment, Is.EqualTo("This is a System audit trail")); } } + + [Test] + public void Get_Paged_Items() + { + var sp = TestObjects.GetScopeProvider(Logger); + using (var scope = sp.CreateScope()) + { + var repo = new AuditRepository((IScopeAccessor) sp, CacheHelper, Logger); + + for (var i = 0; i < 100; i++) + { + repo.Save(new AuditItem(i, $"Content {i} created", AuditType.New, 0)); + repo.Save(new AuditItem(i, $"Content {i} published", AuditType.Publish, 0)); + } + + scope.Complete(); + } + + using (var scope = sp.CreateScope()) + { + var repo = new AuditRepository((IScopeAccessor) sp, CacheHelper, Logger); + + var page = repo.GetPagedResultsByQuery(sp.SqlContext.Query(), 0, 10, out var total, Direction.Descending, null, null); + + Assert.AreEqual(10, page.Count()); + Assert.AreEqual(200, total); + } + } + + [Test] + public void Get_Paged_Items_With_AuditType_Filter() + { + var sp = TestObjects.GetScopeProvider(Logger); + using (var scope = sp.CreateScope()) + { + var repo = new AuditRepository((IScopeAccessor) sp, CacheHelper, Logger); + + for (var i = 0; i < 100; i++) + { + repo.Save(new AuditItem(i, $"Content {i} created", AuditType.New, 0)); + repo.Save(new AuditItem(i, $"Content {i} published", AuditType.Publish, 0)); + } + + scope.Complete(); + } + + using (var scope = sp.CreateScope()) + { + var repo = new AuditRepository((IScopeAccessor) sp, CacheHelper, Logger); + + var page = repo.GetPagedResultsByQuery(sp.SqlContext.Query(), 0, 9, out var total, Direction.Descending, + new[] {AuditType.Publish}, null) + .ToArray(); + + Assert.AreEqual(9, page.Length); + Assert.IsTrue(page.All(x => x.AuditType == AuditType.Publish)); + Assert.AreEqual(100, total); + } + } + + [Test] + public void Get_Paged_Items_With_Custom_Filter() + { + var sp = TestObjects.GetScopeProvider(Logger); + using (var scope = sp.CreateScope()) + { + var repo = new AuditRepository((IScopeAccessor) sp, CacheHelper, Logger); + + for (var i = 0; i < 100; i++) + { + repo.Save(new AuditItem(i, "Content created", AuditType.New, 0)); + repo.Save(new AuditItem(i, "Content published", AuditType.Publish, 0)); + } + + scope.Complete(); + } + + using (var scope = sp.CreateScope()) + { + var repo = new AuditRepository((IScopeAccessor) sp, CacheHelper, Logger); + + var page = repo.GetPagedResultsByQuery(sp.SqlContext.Query(), 0, 8, out var total, Direction.Descending, + null, sp.SqlContext.Query().Where(item => item.Comment == "Content created")) + .ToArray(); + + Assert.AreEqual(8, page.Length); + Assert.IsTrue(page.All(x => x.Comment == "Content created")); + Assert.AreEqual(100, total); + } + } } } diff --git a/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs b/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs index d8323a13ab..47d7395320 100644 --- a/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs +++ b/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs @@ -313,6 +313,9 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.AreEqual(4, contentType.PropertyTypes.Count()); + // remove all templates - since they are not saved, they would break the (wtf) mapping code + contentType.AllowedTemplates = new ITemplate[0]; + // there is NO mapping from display to contentType, but only from save // to contentType, so if we want to test, let's to it properly! var display = Mapper.Map(contentType); diff --git a/src/Umbraco.Tests/Persistence/Repositories/SimilarNodeNameTests.cs b/src/Umbraco.Tests/Persistence/Repositories/SimilarNodeNameTests.cs index 1485e3c185..3c23223c9f 100644 --- a/src/Umbraco.Tests/Persistence/Repositories/SimilarNodeNameTests.cs +++ b/src/Umbraco.Tests/Persistence/Repositories/SimilarNodeNameTests.cs @@ -21,6 +21,8 @@ namespace Umbraco.Tests.Persistence.Repositories [TestCase("Alpha (10)", "Alpha (2)", +1)] // this is the real stuff [TestCase("Kilo", "Golf (2)", +1)] [TestCase("Kilo (1)", "Golf (2)", +1)] + [TestCase("", "", 0)] + [TestCase(null, null, 0)] public void ComparerTest(string name1, string name2, int expected) { var comparer = new SimilarNodeName.Comparer(); @@ -73,6 +75,8 @@ namespace Umbraco.Tests.Persistence.Repositories [TestCase(0, "Alpha", "Alpha (3)")] [TestCase(0, "Kilo (1)", "Kilo (1) (1)")] // though... we might consider "Kilo (2)" [TestCase(6, "Kilo (1)", "Kilo (1)")] // because of the id + [TestCase(0, "", " (1)")] + [TestCase(0, null, " (1)")] public void Test(int nodeId, string nodeName, string expected) { var names = new[] diff --git a/src/Umbraco.Tests/Persistence/Repositories/UserRepositoryTest.cs b/src/Umbraco.Tests/Persistence/Repositories/UserRepositoryTest.cs index 03ea7391f9..a636e956f9 100644 --- a/src/Umbraco.Tests/Persistence/Repositories/UserRepositoryTest.cs +++ b/src/Umbraco.Tests/Persistence/Repositories/UserRepositoryTest.cs @@ -366,7 +366,8 @@ namespace Umbraco.Tests.Persistence.Repositories var group = MockedUserGroup.CreateUserGroup(); userGroupRepository.AddOrUpdateGroupWithUsers(@group, new[] { user.Id }); - + + user.AddGroup(group); return user; } diff --git a/src/Umbraco.Tests/PropertyEditors/ColorListValidatorTest.cs b/src/Umbraco.Tests/PropertyEditors/ColorListValidatorTest.cs index 453e6eab62..200cccad5b 100644 --- a/src/Umbraco.Tests/PropertyEditors/ColorListValidatorTest.cs +++ b/src/Umbraco.Tests/PropertyEditors/ColorListValidatorTest.cs @@ -15,7 +15,7 @@ namespace Umbraco.Tests.PropertyEditors public void Only_Tests_On_JArray() { var validator = new ColorPickerConfigurationEditor.ColorListValidator(); - var result = validator.Validate("hello", null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + var result = validator.Validate("hello", null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(0, result.Count()); } @@ -23,7 +23,7 @@ namespace Umbraco.Tests.PropertyEditors public void Only_Tests_On_JArray_Of_Item_JObject() { var validator = new ColorPickerConfigurationEditor.ColorListValidator(); - var result = validator.Validate(new JArray("hello", "world"), null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + var result = validator.Validate(new JArray("hello", "world"), null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(0, result.Count()); } @@ -36,7 +36,7 @@ namespace Umbraco.Tests.PropertyEditors JObject.FromObject(new { value = "zxcvzxcvxzcv" }), JObject.FromObject(new { value = "ABC" }), JObject.FromObject(new { value = "1234567" })), - null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(2, result.Count()); } } diff --git a/src/Umbraco.Tests/PropertyEditors/EnsureUniqueValuesValidatorTest.cs b/src/Umbraco.Tests/PropertyEditors/EnsureUniqueValuesValidatorTest.cs index 98ca964d89..6d4c6f940a 100644 --- a/src/Umbraco.Tests/PropertyEditors/EnsureUniqueValuesValidatorTest.cs +++ b/src/Umbraco.Tests/PropertyEditors/EnsureUniqueValuesValidatorTest.cs @@ -15,7 +15,7 @@ namespace Umbraco.Tests.PropertyEditors public void Only_Tests_On_JArray() { var validator = new ValueListUniqueValueValidator(); - var result = validator.Validate("hello", null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + var result = validator.Validate("hello", null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(0, result.Count()); } @@ -23,7 +23,7 @@ namespace Umbraco.Tests.PropertyEditors public void Only_Tests_On_JArray_Of_Item_JObject() { var validator = new ValueListUniqueValueValidator(); - var result = validator.Validate(new JArray("hello", "world"), null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + var result = validator.Validate(new JArray("hello", "world"), null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(0, result.Count()); } @@ -31,7 +31,7 @@ namespace Umbraco.Tests.PropertyEditors public void Allows_Unique_Values() { var validator = new ValueListUniqueValueValidator(); - var result = validator.Validate(new JArray(JObject.FromObject(new { value = "hello" }), JObject.FromObject(new { value = "world" })), null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + var result = validator.Validate(new JArray(JObject.FromObject(new { value = "hello" }), JObject.FromObject(new { value = "world" })), null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(0, result.Count()); } @@ -40,7 +40,7 @@ namespace Umbraco.Tests.PropertyEditors { var validator = new ValueListUniqueValueValidator(); var result = validator.Validate(new JArray(JObject.FromObject(new { value = "hello" }), JObject.FromObject(new { value = "hello" })), - null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(1, result.Count()); } @@ -53,7 +53,7 @@ namespace Umbraco.Tests.PropertyEditors JObject.FromObject(new { value = "hello" }), JObject.FromObject(new { value = "world" }), JObject.FromObject(new { value = "world" })), - null, new ColorPickerPropertyEditor(Mock.Of(), Mock.Of())); + null, new ColorPickerPropertyEditor(Mock.Of())); Assert.AreEqual(2, result.Count()); } } diff --git a/src/Umbraco.Tests/Routing/UmbracoModuleTests.cs b/src/Umbraco.Tests/Routing/UmbracoModuleTests.cs index 56ab73e6f3..9e21aa3d25 100644 --- a/src/Umbraco.Tests/Routing/UmbracoModuleTests.cs +++ b/src/Umbraco.Tests/Routing/UmbracoModuleTests.cs @@ -95,8 +95,14 @@ namespace Umbraco.Tests.Routing Assert.AreEqual(assert, result); } - - + [Test] + public void Is_Client_Side_Request_InvalidPath_ReturnFalse() + { + //This url is invalid. Default to false when the extension cannot be determined + var uri = new Uri("http://test.com/installing-modules+foobar+\"yipee\""); + var result = uri.IsClientSideRequest(); + Assert.AreEqual(false, result); + } //NOTE: This test shows how we can test most of the HttpModule, it however is testing a method that no longer exists and is testing too much, // we need to write unit tests for each of the components: NiceUrlProvider, all of the Lookup classes, etc... diff --git a/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs b/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs index 9cab37f39e..842fe50e04 100644 --- a/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs +++ b/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.Linq; using LightInject; using Moq; using NUnit.Framework; +using Umbraco.Core.Collections; using Umbraco.Core.Events; using Umbraco.Core.Models; using Umbraco.Core.IO; @@ -12,6 +14,7 @@ using Umbraco.Tests.TestHelpers; using Umbraco.Tests.TestHelpers.Entities; using Umbraco.Core.Composing; using Umbraco.Core.Persistence.Mappers; +using Umbraco.Core.Services; namespace Umbraco.Tests.Scoping { @@ -139,7 +142,7 @@ namespace Umbraco.Tests.Scoping //content1 will be filtered from the args scope.Events.Dispatch(DoSaveForContent, this, new SaveEventArgs(new[]{ content1 , content3})); - scope.Events.Dispatch(DoDeleteForContent, this, new DeleteEventArgs(content1)); + scope.Events.Dispatch(DoDeleteForContent, this, new DeleteEventArgs(content1), "DoDeleteForContent"); scope.Events.Dispatch(DoSaveForContent, this, new SaveEventArgs(content2)); //this entire event will be filtered scope.Events.Dispatch(DoForTestArgs, this, new TestEventArgs(content1)); @@ -163,6 +166,27 @@ namespace Umbraco.Tests.Scoping } } + [Test] + public void SupersededEvents2() + { + Test_UnPublished += OnDoThingFail; + Test_Deleted += OnDoThingFail; + + var contentService = Mock.Of(); + var content = Mock.Of(); + + var scopeProvider = _testObjects.GetScopeProvider(Mock.Of()); + using (var scope = scopeProvider.CreateScope(eventDispatcher: new PassiveEventDispatcher())) + { + scope.Events.Dispatch(Test_UnPublished, contentService, new PublishEventArgs(new [] { content }), "UnPublished"); + scope.Events.Dispatch(Test_Deleted, contentService, new DeleteEventArgs(new [] { content }), "Deleted"); + + // see U4-10764 + var events = scope.Events.GetEvents(EventDefinitionFilter.All).ToArray(); + Assert.AreEqual(2, events.Length); + } + } + /// /// This will test that when we track events that before we Get the events we normalize all of the /// event entities to be the latest one (most current) found amongst the event so that there is @@ -371,6 +395,9 @@ namespace Umbraco.Tests.Scoping public static event TypedEventHandler> DoThing3; + public static event TypedEventHandler> Test_UnPublished; + public static event TypedEventHandler> Test_Deleted; + public class TestEventArgs : CancellableObjectEventArgs { public TestEventArgs(object eventObject) : base(eventObject) diff --git a/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs b/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs index 74b74ad1ce..f71c73d26e 100644 --- a/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs +++ b/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs @@ -22,6 +22,7 @@ namespace Umbraco.Tests.Security public void Create_From_Claims_Identity() { var sessionId = Guid.NewGuid().ToString(); + var securityStamp = Guid.NewGuid().ToString(); var claimsIdentity = new ClaimsIdentity(new[] { //This is the id that 'identity' uses to check for the user id @@ -36,12 +37,14 @@ namespace Umbraco.Tests.Security new Claim(ClaimTypes.Locality, "en-us", ClaimValueTypes.String, TestIssuer, TestIssuer), new Claim(Constants.Security.SessionIdClaimType, sessionId, Constants.Security.SessionIdClaimType, TestIssuer, TestIssuer), new Claim(ClaimsIdentity.DefaultRoleClaimType, "admin", ClaimValueTypes.String, TestIssuer, TestIssuer), + new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, securityStamp, ClaimValueTypes.String, TestIssuer, TestIssuer), }); var backofficeIdentity = UmbracoBackOfficeIdentity.FromClaimsIdentity(claimsIdentity); Assert.AreEqual("1234", backofficeIdentity.Id); Assert.AreEqual(sessionId, backofficeIdentity.SessionId); + Assert.AreEqual(securityStamp, backofficeIdentity.SecurityStamp); Assert.AreEqual("testing", backofficeIdentity.Username); Assert.AreEqual("hello world", backofficeIdentity.RealName); Assert.AreEqual(1, backofficeIdentity.StartContentNodes.Length); @@ -50,7 +53,7 @@ namespace Umbraco.Tests.Security Assert.AreEqual("en-us", backofficeIdentity.Culture); Assert.IsTrue(new[] { "admin" }.SequenceEqual(backofficeIdentity.Roles)); - Assert.AreEqual(10, backofficeIdentity.Claims.Count()); + Assert.AreEqual(11, backofficeIdentity.Claims.Count()); } [Test] @@ -93,6 +96,7 @@ namespace Umbraco.Tests.Security var sessionId = Guid.NewGuid().ToString(); var userData = new UserData(sessionId) { + SecurityStamp = sessionId, AllowedApplications = new[] {"content", "media"}, Culture = "en-us", Id = 1234, @@ -113,6 +117,7 @@ namespace Umbraco.Tests.Security var sessionId = Guid.NewGuid().ToString(); var userData = new UserData(sessionId) { + SecurityStamp = sessionId, AllowedApplications = new[] { "content", "media" }, Culture = "en-us", Id = 1234, @@ -139,6 +144,7 @@ namespace Umbraco.Tests.Security var sessionId = Guid.NewGuid().ToString(); var userData = new UserData(sessionId) { + SecurityStamp = sessionId, AllowedApplications = new[] { "content", "media" }, Culture = "en-us", Id = 1234, @@ -162,6 +168,7 @@ namespace Umbraco.Tests.Security var sessionId = Guid.NewGuid().ToString(); var userData = new UserData(sessionId) { + SecurityStamp = sessionId, AllowedApplications = new[] { "content", "media" }, Culture = "en-us", Id = 1234, diff --git a/src/Umbraco.Tests/Services/AuditServiceTests.cs b/src/Umbraco.Tests/Services/AuditServiceTests.cs new file mode 100644 index 0000000000..6064fe4acc --- /dev/null +++ b/src/Umbraco.Tests/Services/AuditServiceTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using NUnit.Framework; +using Umbraco.Core.Services.Implement; +using Umbraco.Tests.TestHelpers; +using Umbraco.Tests.Testing; + +namespace Umbraco.Tests.Services +{ + [TestFixture] + [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] + public class AuditServiceTests : TestWithDatabaseBase + { + [Test] + public void CanCrudAuditEntry() + { + var yesterday = DateTime.UtcNow.AddDays(-1); + var entry = ServiceContext.AuditService.Write(123, "user 123, bob@example.com", null, yesterday, 456, "user 456, alice@example.com", "umbraco/user", "change property whatever value"); + Assert.AreEqual(123, entry.PerformingUserId); + Assert.AreEqual("user 123, bob@example.com", entry.PerformingDetails); + Assert.AreEqual(yesterday, entry.EventDateUtc); + Assert.AreEqual(456, entry.AffectedUserId); + Assert.AreEqual("user 456, alice@example.com", entry.AffectedDetails); + Assert.AreEqual("umbraco/user", entry.EventType); + Assert.AreEqual("change property whatever value", entry.EventDetails); + + var entries = ((AuditService)ServiceContext.AuditService).GetAll().ToArray(); + Assert.IsNotNull(entries); + Assert.AreEqual(1, entries.Length); + Assert.AreEqual(123, entries[0].PerformingUserId); + + for (var i = 0; i < 10; i++) + { + yesterday = yesterday.AddMinutes(1); + entry = ServiceContext.AuditService.Write(123 + i, "user 123, bob@example.com", null, yesterday, 456 + i, "user 456, alice@example.com", "umbraco/user", "change property whatever value"); + } + + // + // page 0 contains 123+9, 123+8 + // page 1 contains 123+7, 123+6 + // page 2 contains 123+5, 123+4 + // ... + + entries = ((AuditService)ServiceContext.AuditService).GetPage(2, 2, out var count).ToArray(); + + Assert.AreEqual(2, entries.Length); + + Assert.AreEqual(123 + 5, entries[0].PerformingUserId); + Assert.AreEqual(123 + 4, entries[1].PerformingUserId); + } + } +} diff --git a/src/Umbraco.Tests/Services/ConsentServiceTests.cs b/src/Umbraco.Tests/Services/ConsentServiceTests.cs new file mode 100644 index 0000000000..4aa4209673 --- /dev/null +++ b/src/Umbraco.Tests/Services/ConsentServiceTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Linq; +using NUnit.Framework; +using Umbraco.Core.Models; +using Umbraco.Tests.TestHelpers; +using Umbraco.Tests.Testing; + +namespace Umbraco.Tests.Services +{ + [TestFixture] + [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] + public class ConsentServiceTests : TestWithDatabaseBase + { + [Test] + public void CanCrudConsent() + { + var consentService = ServiceContext.ConsentService; + + // can register + + var consent = consentService.RegisterConsent("user/1234", "app1", "do-something", ConsentState.Granted, "no comment"); + Assert.AreNotEqual(0, consent.Id); + + Assert.IsTrue(consent.Current); + Assert.AreEqual("user/1234", consent.Source); + Assert.AreEqual("app1", consent.Context); + Assert.AreEqual("do-something", consent.Action); + Assert.AreEqual(ConsentState.Granted, consent.State); + Assert.AreEqual("no comment", consent.Comment); + + Assert.IsTrue(consent.IsGranted()); + + // can register more + + consentService.RegisterConsent("user/1234", "app1", "do-something-else", ConsentState.Granted, "no comment"); + consentService.RegisterConsent("user/1236", "app1", "do-something", ConsentState.Granted, "no comment"); + consentService.RegisterConsent("user/1237", "app2", "do-something", ConsentState.Granted, "no comment"); + + // can get by source + + var consents = consentService.LookupConsent(source: "user/1235").ToArray(); + Assert.IsEmpty(consents); + + consents = consentService.LookupConsent(source: "user/1234").ToArray(); + Assert.AreEqual(2, consents.Length); + Assert.IsTrue(consents.All(x => x.Source == "user/1234")); + Assert.IsTrue(consents.Any(x => x.Action == "do-something")); + Assert.IsTrue(consents.Any(x => x.Action == "do-something-else")); + + // can get by context + + consents = consentService.LookupConsent(context: "app3").ToArray(); + Assert.IsEmpty(consents); + + consents = consentService.LookupConsent(context: "app2").ToArray(); + Assert.AreEqual(1, consents.Length); + + consents = consentService.LookupConsent(context: "app1").ToArray(); + Assert.AreEqual(3, consents.Length); + Assert.IsTrue(consents.Any(x => x.Action == "do-something")); + Assert.IsTrue(consents.Any(x => x.Action == "do-something-else")); + + // can get by action + + consents = consentService.LookupConsent(action: "do-whatever").ToArray(); + Assert.IsEmpty(consents); + + consents = consentService.LookupConsent(context: "app1", action: "do-something").ToArray(); + Assert.AreEqual(2, consents.Length); + Assert.IsTrue(consents.All(x => x.Action == "do-something")); + Assert.IsTrue(consents.Any(x => x.Source == "user/1234")); + Assert.IsTrue(consents.Any(x => x.Source == "user/1236")); + + // can revoke + + consent = consentService.RegisterConsent("user/1234", "app1", "do-something", ConsentState.Revoked, "no comment"); + + consents = consentService.LookupConsent(source: "user/1234", context: "app1", action: "do-something").ToArray(); + Assert.AreEqual(1, consents.Length); + Assert.IsTrue(consents[0].Current); + Assert.AreEqual(ConsentState.Revoked, consents[0].State); + + // can filter + + consents = consentService.LookupConsent(context: "app1", action: "do-", actionStartsWith: true).ToArray(); + Assert.AreEqual(3, consents.Length); + Assert.IsTrue(consents.All(x => x.Context == "app1")); + Assert.IsTrue(consents.All(x => x.Action.StartsWith("do-"))); + + // can get history + + consents = consentService.LookupConsent(source: "user/1234", context: "app1", action: "do-something", includeHistory: true).ToArray(); + Assert.AreEqual(1, consents.Length); + Assert.IsTrue(consents[0].Current); + Assert.AreEqual(ConsentState.Revoked, consents[0].State); + Assert.IsTrue(consents[0].IsRevoked()); + Assert.IsNotNull(consents[0].History); + var history = consents[0].History.ToArray(); + Assert.AreEqual(1, history.Length); + Assert.IsFalse(history[0].Current); + Assert.AreEqual(ConsentState.Granted, history[0].State); + + // cannot be stupid + + Assert.Throws(() => + consentService.RegisterConsent("user/1234", "app1", "do-something", ConsentState.Granted | ConsentState.Revoked, "no comment")); + } + } +} diff --git a/src/Umbraco.Tests/Services/EntityServiceTests.cs b/src/Umbraco.Tests/Services/EntityServiceTests.cs index 6c465c0522..3d5c2f6ef0 100644 --- a/src/Umbraco.Tests/Services/EntityServiceTests.cs +++ b/src/Umbraco.Tests/Services/EntityServiceTests.cs @@ -529,6 +529,16 @@ namespace Umbraco.Tests.Services Assert.AreEqual(mediaObjectType, UmbracoObjectTypes.MediaType); } + [Test] + public void EntityService_Can_Get_Key_For_Id_With_Unknown_Type() + { + var service = ServiceContext.EntityService; + var result = service.GetKey(1061, UmbracoObjectTypes.Unknown); + + Assert.IsTrue(result.Success); + Assert.AreEqual(Guid.Parse("1D3A8E6E-2EA9-4CC1-B229-1AEE19821522"), result.Result); + } + [Test] public void EntityService_Can_Get_Key_For_Id() { @@ -550,6 +560,16 @@ namespace Umbraco.Tests.Services Assert.IsFalse(result2.Success); } + [Test] + public void EntityService_Can_Get_Id_For_Key_With_Unknown_Type() + { + var service = ServiceContext.EntityService; + var result = service.GetId(Guid.Parse("1D3A8E6E-2EA9-4CC1-B229-1AEE19821522"), UmbracoObjectTypes.Unknown); + + Assert.IsTrue(result.Success); + Assert.AreEqual(1061, result.Result); + } + [Test] public void EntityService_Can_Get_Id_For_Key() { @@ -571,6 +591,30 @@ namespace Umbraco.Tests.Services Assert.IsFalse(result2.Success); } + [Test] + public void ReserveId() + { + var service = ServiceContext.EntityService; + var guid = Guid.NewGuid(); + + // can reserve + var reservedId = service.ReserveId(guid); + Assert.IsTrue(reservedId > 0); + + // can get it back + var id = service.GetId(guid, UmbracoObjectTypes.DocumentType); + Assert.IsTrue(id.Success); + Assert.AreEqual(reservedId, id.Result); + + // anything goes + id = service.GetId(guid, UmbracoObjectTypes.Media); + Assert.IsTrue(id.Success); + Assert.AreEqual(reservedId, id.Result); + + // a random guid won't work + Assert.IsFalse(service.GetId(Guid.NewGuid(), UmbracoObjectTypes.DocumentType).Success); + } + private static bool _isSetup = false; private int folderId; diff --git a/src/Umbraco.Tests/Strings/StringValidationTests.cs b/src/Umbraco.Tests/Strings/StringValidationTests.cs index 710042b188..24ba6fbb2a 100644 --- a/src/Umbraco.Tests/Strings/StringValidationTests.cs +++ b/src/Umbraco.Tests/Strings/StringValidationTests.cs @@ -19,6 +19,8 @@ namespace Umbraco.Tests.Strings Assert.IsTrue(foo.IsValid("futureTLD@somewhere.fooo")); Assert.IsTrue(foo.IsValid("abc@xyz.financial")); + Assert.IsTrue(foo.IsValid("admin+gmail-syntax@c.pizza")); + Assert.IsTrue(foo.IsValid("admin@c.pizza")); Assert.IsFalse(foo.IsValid("fdsa")); Assert.IsFalse(foo.IsValid("fdsa@")); diff --git a/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs b/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs index b45f25c0eb..c456475acc 100644 --- a/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs +++ b/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs @@ -24,9 +24,11 @@ namespace Umbraco.Tests.TestHelpers.ControllerTesting { protected override Task AuthenticateCoreAsync() { + var sessionId = Guid.NewGuid().ToString(); var identity = new UmbracoBackOfficeIdentity( - new UserData(Guid.NewGuid().ToString()) + new UserData(sessionId) { + SecurityStamp = sessionId, Id = 0, Roles = new[] { "admin" }, AllowedApplications = new[] { "content", "media", "members" }, diff --git a/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs b/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs index 2d969762a7..d5213cbcda 100644 --- a/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs +++ b/src/Umbraco.Tests/TestHelpers/ControllerTesting/TestControllerActivatorBase.cs @@ -51,12 +51,20 @@ namespace Umbraco.Tests.TestHelpers.ControllerTesting var mockedContentService = Mock.Of(); var mockedMediaService = Mock.Of(); var mockedEntityService = Mock.Of(); + var mockedMemberService = Mock.Of(); + var mockedMemberTypeService = Mock.Of(); + var mockedDataTypeService = Mock.Of(); + var mockedContentTypeService = Mock.Of(); var serviceContext = new ServiceContext( userService: mockedUserService, contentService: mockedContentService, mediaService: mockedMediaService, entityService: mockedEntityService, + memberService: mockedMemberService, + memberTypeService: mockedMemberTypeService, + dataTypeService: mockedDataTypeService, + contentTypeService: mockedContentTypeService, localizedTextService:Mock.Of(), sectionService:Mock.Of()); diff --git a/src/Umbraco.Tests/TestHelpers/TestObjects.cs b/src/Umbraco.Tests/TestHelpers/TestObjects.cs index 769fe52d4b..d29ba5e41e 100644 --- a/src/Umbraco.Tests/TestHelpers/TestObjects.cs +++ b/src/Umbraco.Tests/TestHelpers/TestObjects.cs @@ -119,7 +119,7 @@ namespace Umbraco.Tests.TestHelpers var publicAccessService = GetLazyService(container, c => new PublicAccessService(scopeProvider, logger, eventMessagesFactory, GetRepo(c))); var taskService = GetLazyService(container, c => new TaskService(scopeProvider, logger, eventMessagesFactory, GetRepo(c), GetRepo(c))); var domainService = GetLazyService(container, c => new DomainService(scopeProvider, logger, eventMessagesFactory, GetRepo(c))); - var auditService = GetLazyService(container, c => new AuditService(scopeProvider, logger, eventMessagesFactory, GetRepo(c))); + var auditService = GetLazyService(container, c => new AuditService(scopeProvider, logger, eventMessagesFactory, GetRepo(c), GetRepo(c))); var localizedTextService = GetLazyService(container, c => new LocalizedTextService( new Lazy(() => @@ -183,6 +183,7 @@ namespace Umbraco.Tests.TestHelpers var tagService = GetLazyService(container, c => new TagService(scopeProvider, logger, eventMessagesFactory, GetRepo(c))); var sectionService = GetLazyService(container, c => new SectionService(userService.Value, treeService.Value, scopeProvider, cache)); var redirectUrlService = GetLazyService(container, c => new RedirectUrlService(scopeProvider, logger, eventMessagesFactory, GetRepo(c))); + var consentService = GetLazyService(container, c => new ConsentService(scopeProvider, logger, eventMessagesFactory, GetRepo(c))); return new ServiceContext( publicAccessService, @@ -211,7 +212,8 @@ namespace Umbraco.Tests.TestHelpers memberGroupService, notificationService, externalLoginService, - redirectUrlService); + redirectUrlService, + consentService); } private Lazy GetLazyService(IServiceFactory container, Func ctor) diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index 4ada50653a..1d235bdbef 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -257,7 +257,9 @@ + + diff --git a/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs b/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs index 51996955b1..95c95b4e71 100644 --- a/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs +++ b/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs @@ -20,6 +20,7 @@ using Umbraco.Tests.TestHelpers.Entities; using Umbraco.Tests.Testing; using Umbraco.Web; using Umbraco.Web.Editors; +using Umbraco.Web.Features; using Umbraco.Web.Models.ContentEditing; using IUser = Umbraco.Core.Models.Membership.IUser; @@ -40,6 +41,8 @@ namespace Umbraco.Tests.Web.Controllers // kill the true IEntityService too Container.RegisterSingleton(f => Mock.Of()); + + Container.RegisterSingleton(); } [Test] diff --git a/src/Umbraco.Tests/Web/Mvc/HtmlStringUtilitiesTests.cs b/src/Umbraco.Tests/Web/Mvc/HtmlStringUtilitiesTests.cs index 4630a89772..102a310806 100644 --- a/src/Umbraco.Tests/Web/Mvc/HtmlStringUtilitiesTests.cs +++ b/src/Umbraco.Tests/Web/Mvc/HtmlStringUtilitiesTests.cs @@ -22,5 +22,40 @@ namespace Umbraco.Tests.Web.Mvc var expected = "

hello world

hello world
hello world
hello world
hello world

"; Assert.AreEqual(expected, output); } + + [Test] + public void TruncateWithElipsis() + { + var output = _htmlStringUtilities.Truncate("hello world", 5, true, false).ToString(); + var expected = "hello…"; + Assert.AreEqual(expected, output); + } + + [Test] + public void TruncateWithoutElipsis() + { + var output = _htmlStringUtilities.Truncate("hello world", 5, false, false).ToString(); + var expected = "hello"; + Assert.AreEqual(expected, output); + } + + [Test] + public void TruncateShorterWordThanHellip() + { + //http://issues.umbraco.org/issue/U4-10478 + var output = _htmlStringUtilities.Truncate("hi", 5, true, false).ToString(); + var expected = "hi"; + Assert.AreEqual(expected, output); + } + + [Test] + public void TruncateAndRemoveSpaceBetweenHellipAndWord() + { + var output = _htmlStringUtilities.Truncate("hello world", 6 /* hello plus space */, true, false).ToString(); + var expected = "hello…"; + Assert.AreEqual(expected, output); + } + + } -} +} \ No newline at end of file diff --git a/src/Umbraco.Tests/Web/Mvc/RenderModelBinderTests.cs b/src/Umbraco.Tests/Web/Mvc/RenderModelBinderTests.cs index 4532ed3679..3b60f1a7c5 100644 --- a/src/Umbraco.Tests/Web/Mvc/RenderModelBinderTests.cs +++ b/src/Umbraco.Tests/Web/Mvc/RenderModelBinderTests.cs @@ -32,7 +32,7 @@ namespace Umbraco.Tests.Web.Mvc [Test] public void Returns_Binder_For_IPublishedContent_And_IRenderModel() { - var binder = new ContentModelBinder(); + var binder = ContentModelBinder.Instance; var found = binder.GetBinder(typeof (IPublishedContent)); Assert.IsNotNull(found); found = binder.GetBinder(typeof(ContentModel)); @@ -92,7 +92,7 @@ namespace Umbraco.Tests.Web.Mvc [Test] public void No_DataToken_Returns_Null() { - var binder = new ContentModelBinder(); + var binder = ContentModelBinder.Instance; var routeData = new RouteData(); var result = binder.BindModel(new ControllerContext(Mock.Of(), routeData, Mock.Of()), new ModelBindingContext()); @@ -103,7 +103,7 @@ namespace Umbraco.Tests.Web.Mvc [Test] public void Invalid_DataToken_Model_Type_Returns_Null() { - var binder = new ContentModelBinder(); + var binder = ContentModelBinder.Instance; var routeData = new RouteData(); routeData.DataTokens[Core.Constants.Web.UmbracoDataToken] = "hello"; @@ -133,7 +133,7 @@ namespace Umbraco.Tests.Web.Mvc public void IPublishedContent_DataToken_Model_Type_Uses_DefaultImplementation() { var content = new MyContent(Mock.Of()); - var binder = new ContentModelBinder(); + var binder = ContentModelBinder.Instance; var routeData = new RouteData(); routeData.DataTokens[Core.Constants.Web.UmbracoDataToken] = content; diff --git a/src/Umbraco.Tests/Web/TemplateUtilitiesTests.cs b/src/Umbraco.Tests/Web/TemplateUtilitiesTests.cs index e2019193f9..5e33aec072 100644 --- a/src/Umbraco.Tests/Web/TemplateUtilitiesTests.cs +++ b/src/Umbraco.Tests/Web/TemplateUtilitiesTests.cs @@ -41,6 +41,8 @@ namespace Umbraco.Tests.Web Current.Container = container.Object; Umbraco.Web.Composing.Current.UmbracoContextAccessor = new TestUmbracoContextAccessor(); + + Udi.ResetUdiTypes(); } [TearDown] 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 72e7bf9eed..645b783c65 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 @@ -201,7 +201,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/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index e676187863..8d60ae38e6 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -56,6 +56,8 @@ ..\packages\Examine.1.0.0-beta020\lib\net45\Examine.dll + + ..\packages\ClientDependency.1.9.6\lib\net45\ClientDependency.Core.dll ..\packages\ImageProcessor.Web.4.8.4\lib\net45\ImageProcessor.Web.dll @@ -257,10 +259,6 @@ - - ..\packages\ClientDependency.1.9.2\lib\net45\ClientDependency.Core.dll - True - ../packages/log4net.2.0.8/lib/net45-full/log4net.dll True @@ -951,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/packages.config b/src/Umbraco.Web.UI/packages.config index ce79b88b50..63f9690512 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.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 }))