/*
*
* * Copyright (c) 2016. David Sowerby
* *
* * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* * the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* * specific language governing permissions and limitations under the License.
*
*/
package uk.q3c.krail.core.navigate;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.vaadin.server.Page;
import com.vaadin.server.Page.UriFragmentChangedEvent;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import net.engio.mbassy.bus.common.PubSubSupport;
import net.engio.mbassy.listener.Handler;
import net.engio.mbassy.listener.Listener;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.subject.Subject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.q3c.krail.core.eventbus.BusMessage;
import uk.q3c.krail.core.eventbus.UIBusProvider;
import uk.q3c.krail.core.guice.uiscope.UIScoped;
import uk.q3c.krail.core.navigate.sitemap.*;
import uk.q3c.krail.core.navigate.sitemap.set.MasterSitemapQueue;
import uk.q3c.krail.core.shiro.PageAccessController;
import uk.q3c.krail.core.shiro.SubjectProvider;
import uk.q3c.krail.core.shiro.UnauthorizedExceptionHandler;
import uk.q3c.krail.core.ui.ScopedUI;
import uk.q3c.krail.core.ui.ScopedUIProvider;
import uk.q3c.krail.core.user.status.UserStatusBusMessage;
import uk.q3c.krail.core.view.BeforeViewChangeBusMessage;
import uk.q3c.krail.core.view.DefaultViewFactory;
import uk.q3c.krail.core.view.ErrorView;
import uk.q3c.krail.core.view.KrailView;
import uk.q3c.krail.core.view.component.AfterViewChangeBusMessage;
import uk.q3c.krail.core.view.component.ViewChangeBusMessage;
import java.util.List;
import java.util.Optional;
import static com.google.common.base.Preconditions.checkNotNull;
/**
* The navigator is at the heart of navigation process, and provides navigation for a number of data types (for
* example, String, {@link NavigationState} and {@link UserSitemapNode}.
* <p>
* The navigator implements {@link Page.UriFragmentChangedListener}s to detect changes in URI.
* <p>
* Although the {@link UserSitemap} contains only authorised pages, an additional level of security is added by checking that a user is authorised before
* moving
* to another page
* <p>
* The {@link #eventBus} is used to manage view changes - note that the eventBus must be synchronous for the view change cancellation to work (see {@link
* #publishBeforeViewChange(BeforeViewChangeBusMessage)}
*
* @author David Sowerby
* @date 18 Apr 2014
*/
@UIScoped
@Listener
public class DefaultNavigator implements Navigator {
private static Logger log = LoggerFactory.getLogger(DefaultNavigator.class);
private final URIFragmentHandler uriHandler;
private final Provider<Subject> subjectProvider;
private final PageAccessController pageAccessController;
private final ScopedUIProvider uiProvider;
private final DefaultViewFactory viewFactory;
private final SitemapService sitemapService;
private final UserSitemapBuilder userSitemapBuilder;
private final LoginNavigationRule loginNavigationRule;
private final LogoutNavigationRule logoutNavigationRule;
private final InvalidURIHandler invalidURIHandler;
private MasterSitemapQueue masterSitemapQueue;
private MasterSitemap masterSitemap;
private NavigationState currentNavigationState;
private KrailView currentView = null;
private PubSubSupport<BusMessage> eventBus;
private NavigationState previousNavigationState;
private UserSitemap userSitemap;
private ViewChangeRule viewChangeRule;
@Inject
public DefaultNavigator(URIFragmentHandler uriHandler, SitemapService sitemapService, SubjectProvider subjectProvider, PageAccessController
pageAccessController, ScopedUIProvider uiProvider, DefaultViewFactory viewFactory, UserSitemapBuilder userSitemapBuilder, LoginNavigationRule
loginNavigationRule, LogoutNavigationRule logoutNavigationRule, UIBusProvider eventBusProvider, ViewChangeRule
viewChangeRule, InvalidURIHandler invalidURIHandler, MasterSitemapQueue masterSitemapQueue) {
super();
this.uriHandler = uriHandler;
this.uiProvider = uiProvider;
this.sitemapService = sitemapService;
this.subjectProvider = subjectProvider;
this.pageAccessController = pageAccessController;
this.viewFactory = viewFactory;
this.userSitemapBuilder = userSitemapBuilder;
this.loginNavigationRule = loginNavigationRule;
this.logoutNavigationRule = logoutNavigationRule;
this.invalidURIHandler = invalidURIHandler;
this.masterSitemapQueue = masterSitemapQueue;
this.eventBus = eventBusProvider.get();
this.viewChangeRule = viewChangeRule;
}
@SuppressFBWarnings("EXS_EXCEPTION_SOFTENING_NO_CHECKED")
@Override
public void init() {
try {
sitemapService.start();
//take a reference and keep it in case current model changes
this.masterSitemap = masterSitemapQueue.getCurrentModel();
userSitemapBuilder.setMasterSitemap(masterSitemap);
userSitemapBuilder.build();
userSitemap = userSitemapBuilder.getUserSitemap();
} catch (Exception e) {
String msg = "Sitemap service failed to start, application will have no pages";
log.error(msg);
throw new IllegalStateException(msg, e);
}
}
@Override
public void uriFragmentChanged(UriFragmentChangedEvent event) {
navigateTo(event.getUriFragment());
}
/**
* Takes a URI fragment, checks for any redirects defined by the {@link Sitemap}, then calls
* {@link #navigateTo(NavigationState)} to change the view
*
* @see Navigator#navigateTo(java.lang.String)
*/
@Override
public void navigateTo(String fragment) {
log.debug("Navigating to fragment: {}", fragment);
// set up the navigation state
NavigationState navigationState = uriHandler.navigationState(fragment);
navigateTo(navigationState);
}
/**
* Navigates to a the location represented by {@code navigationState}. If the {@link Sitemap} holds a redirect for
* the URI represented by {@code navigationState}, navigation will be directed to the redirect target. An
* unrecognised URI will throw a {@link SitemapException}. If the view for the URI is found, the user's
* authorisation is checked. If the user is not authorised, a {@link AuthorizationException} is thrown. This would
* be caught by the the implementation bound to {@link UnauthorizedExceptionHandler}. If the user is authorised,
* the
* View is instantiated, and made the current view in the UI via {@link ScopedUI#changeView(KrailView)}.<br>
* <br>
* Messages are published to the {{@link #eventBus}} before and after the view change. Message handlers have the
* option to block the view change by returning false (see {@link #publishBeforeViewChange(BeforeViewChangeBusMessage)}
* <p>
*
* @param navigationState The navigationState to navigate to. May not be null.
*/
@Override
public void navigateTo(NavigationState navigationState) {
checkNotNull(navigationState);
//computer says no
if (!viewChangeRule.changeIsAllowed(this, currentView)) {
return;
}
//makes sure the navigation state is up to date, removes the need to do this externally
uriHandler.updateFragment(navigationState);
redirectIfNeeded(navigationState);
// stop unnecessary changes, but also to prevent navigation aware
// components from causing a loop by responding to a change of URI (they should suppress events when they do,
// but may not)
if (navigationState.equals(currentNavigationState)) {
log.debug("fragment unchanged, no navigation required");
return;
}
// https://sites.google.com/site/q3cjava/sitemap#emptyURI
if (navigationState.getVirtualPage()
.isEmpty()) {
navigationState.virtualPage(userSitemap.standardPageURI(StandardPageKey.Public_Home));
uriHandler.updateFragment(navigationState);
}
String virtualPage = navigationState.getVirtualPage();
log.debug("obtaining view for '{}'", virtualPage);
UserSitemapNode node = userSitemap.nodeFor(navigationState);
if (node == null) {
invalidURIHandler.invoke(this, virtualPage);
return;
}
Subject subject = subjectProvider.get();
boolean authorised = pageAccessController.isAuthorised(subject, masterSitemap, node);
if (authorised) {
// need this in case the change is blocked by a listener
NavigationState previousPreviousNavigationState = previousNavigationState;
previousNavigationState = currentNavigationState;
currentNavigationState = navigationState;
BeforeViewChangeBusMessage beforeMessage = new BeforeViewChangeBusMessage(previousNavigationState, navigationState);
// if change is blocked revert to previous state
if (!publishBeforeViewChange(beforeMessage)) {
currentNavigationState = previousNavigationState;
previousNavigationState = previousPreviousNavigationState;
return;
}
// make sure the page uri is updated if necessary, but do not fire any change events
// as we have already responded to the change
ScopedUI ui = uiProvider.get();
Page page = ui.getPage();
String fragment = navigationState.getFragment();
if (!fragment
.equals(page.getUriFragment())) {
page.setUriFragment(fragment, false);
}
// now change the view
KrailView view = viewFactory.get(node.getViewClass());
AfterViewChangeBusMessage afterMessage = new AfterViewChangeBusMessage(beforeMessage);
changeView(view, afterMessage);
// and tell listeners its changed
publishAfterViewChange(afterMessage);
} else {
throw new UnauthorizedException(navigationState.getVirtualPage());
}
}
/**
* Checks {@code navigationState} to see whether the {@link Sitemap} defines this as a page which should be
* redirected. If it is, {@code navigationState} is modified, modified for the redirected page. If no
* redirection is required, the {@code navigationState} is returned unchanged.
*
* @param navigationState the proposed navigation state before considering redirection
*/
private void redirectIfNeeded(NavigationState navigationState) {
String page = navigationState.getVirtualPage();
String redirection = userSitemap.getRedirectPageFor(page);
// if no redirect found, do nothing
if (!redirection.equals(page)) {
navigationState.virtualPage(redirection)
.update(uriHandler);
}
}
protected void changeView(KrailView view, ViewChangeBusMessage busMessage) {
ScopedUI ui = uiProvider.get();
log.debug("calling view.beforeBuild(event) for {}", view.getClass()
.getName());
view.beforeBuild(busMessage);
log.debug("calling view.buildView(event) {}", view.getClass()
.getName());
view.buildView(busMessage);
ui.changeView(view);
log.debug("calling view.afterBuild(event) {}", view.getClass()
.getName());
view.afterBuild(new AfterViewChangeBusMessage(busMessage));
currentView = view;
}
/**
* Publishes a message to the {@link #eventBus} before an imminent view change. At this point the {@code message}:<ol> <
* <li><{@code fromState} represents the current navigation state/li>
* li>{@code toState} represents the navigation state which will be moved to if the change is successful.</li></ol>
* <p>
* Message Handlers are called in an undefined order unless {@link Handler#priority()} is used to specify an order. If any handler cancels the event,
* {@link
* BeforeViewChangeBusMessage#cancel()}, false is returned.
*
* @param busMessage view change message from the bus (view change not yet performed)
* @return true if the view change should be allowed, false to silently block the navigation operation
*/
protected boolean publishBeforeViewChange(BeforeViewChangeBusMessage busMessage) {
// must be a synchronous bus, or the blocking mechanism will not work
eventBus.publish(busMessage);
return !busMessage.isCancelled();
}
/**
* Publishes a message to the {@link #eventBus} immediately after a view change.
* <p>
* Message Handlers are called in an undefined order unless {@link Handler#priority()} is used to specify an order.
*
* @param busMessage view change message from the bus
*/
protected void publishAfterViewChange(AfterViewChangeBusMessage busMessage) {
eventBus.publish(busMessage);
}
@Override
public NavigationState getCurrentNavigationState() {
return currentNavigationState;
}
@Override
public List<String> getNavigationParams() {
return currentNavigationState.getParameterList();
}
@Override
public KrailView getCurrentView() {
return currentView;
}
/**
* Returns the NavigationState representing the previous position of the navigator
*
* @return the NavigationState representing the previous position of the navigator
*/
public NavigationState getPreviousNavigationState() {
return previousNavigationState;
}
@Override
public void clearHistory() {
previousNavigationState = null;
}
@Override
public void error(Throwable error) {
log.debug("A {} Error has been thrown, reporting via the Error View", error.getClass()
.getName());
NavigationState navigationState = uriHandler.navigationState("error");
ViewChangeBusMessage viewChangeBusMessage = new ViewChangeBusMessage(previousNavigationState, navigationState);
ErrorView view = viewFactory.get(ErrorView.class);
view.setError(error);
changeView(view, viewChangeBusMessage);
}
/**
* Navigates to a the location represented by {@code node}
*/
@Override
public void navigateTo(UserSitemapNode node) {
navigateTo(userSitemap.uri(node));
}
/**
* Returns the node for the current navigation state. If the node is not fond in the map, a check is also made to
* see whether it is the login node (which will not appear in the map once the user has logged in)
*
* @return
*/
@Override
public UserSitemapNode getCurrentNode() {
UserSitemapNode node = userSitemap.nodeFor(currentNavigationState);
if (node == null) {
if (userSitemap.isLoginUri(currentNavigationState)) {
return userSitemap.standardPageNode(StandardPageKey.Log_In);
} else {
return null;
}
} else {
return node;
}
}
@Override
public UserSitemapNode getPreviousNode() {
return userSitemap.nodeFor(previousNavigationState);
}
/**
* Applies the login / logout navigation rules. Handler priority is set so that Navigators respond after other listeners - they must complete before the
* Navigator attempts to change page
*
* @param busMessage message from the event bus
*/
@Handler(priority = -1)
public void userStatusChange(UserStatusBusMessage busMessage) {
log.debug("UserStatusBusMessage received");
if (busMessage.isAuthenticated()) {
log.info("user logged in successfully, applying login navigation rule");
Optional<NavigationState> newState = loginNavigationRule.changedNavigationState(this, busMessage.getSource());
if (newState.isPresent()) {
navigateTo(newState.get());
}
} else {
log.info("user logged out, applying logout navigation rule");
Optional<NavigationState> newState = logoutNavigationRule.changedNavigationState(this, busMessage.getSource());
if (newState.isPresent()) {
navigateTo(newState.get());
}
}
}
@Override
public void navigateTo(StandardPageKey pageKey) {
navigateTo(userSitemap.standardPageURI(pageKey));
}
}