/**
* Copyright (C) 2010-2017 Structr GmbH
*
* This file is part of Structr <http://structr.org>.
*
* Structr is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Structr is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Structr. If not, see <http://www.gnu.org/licenses/>.
*/
package org.structr.web.servlet;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.AsyncContext;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.WriteListener;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.structr.api.config.Settings;
import org.structr.common.AccessMode;
import org.structr.common.GraphObjectComparator;
import org.structr.common.PathHelper;
import org.structr.common.SecurityContext;
import org.structr.common.ThreadLocalMatcher;
import org.structr.common.error.FrameworkException;
import org.structr.core.GraphObject;
import org.structr.core.Result;
import org.structr.core.Services;
import org.structr.core.app.App;
import org.structr.core.app.Query;
import org.structr.core.app.StructrApp;
import org.structr.core.auth.Authenticator;
import org.structr.core.converter.PropertyConverter;
import org.structr.core.entity.AbstractNode;
import org.structr.core.entity.Principal;
import org.structr.core.graph.Tx;
import org.structr.core.property.PropertyKey;
import org.structr.core.property.PropertyMap;
import org.structr.dynamic.File;
import org.structr.rest.auth.AuthHelper;
import org.structr.rest.service.HttpServiceServlet;
import org.structr.rest.service.StructrHttpServiceConfig;
import org.structr.schema.ConfigurationProvider;
import org.structr.util.Base64;
import org.structr.web.auth.UiAuthenticator;
import org.structr.web.common.FileHelper;
import org.structr.web.common.RenderContext;
import org.structr.web.common.RenderContext.EditMode;
import org.structr.web.common.StringRenderBuffer;
import org.structr.web.entity.AbstractFile;
import org.structr.web.entity.FileBase;
import org.structr.web.entity.Linkable;
import org.structr.web.entity.Site;
import org.structr.web.entity.User;
import org.structr.web.entity.dom.DOMNode;
import org.structr.web.entity.dom.Page;
//~--- classes ----------------------------------------------------------------
/**
* Main servlet for content rendering.
*
*
*
*/
public class HtmlServlet extends HttpServlet implements HttpServiceServlet {
private static final Logger logger = LoggerFactory.getLogger(HtmlServlet.class.getName());
public static final String CONFIRM_REGISTRATION_PAGE = "/confirm_registration";
public static final String RESET_PASSWORD_PAGE = "/reset-password";
public static final String POSSIBLE_ENTRY_POINTS_KEY = "possibleEntryPoints";
public static final String DOWNLOAD_AS_FILENAME_KEY = "filename";
public static final String RANGE_KEY = "range";
public static final String DOWNLOAD_AS_DATA_URL_KEY = "as-data-url";
public static final String CONFIRM_KEY_KEY = "key";
public static final String TARGET_PAGE_KEY = "target";
public static final String ERROR_PAGE_KEY = "onerror";
public static final String CUSTOM_RESPONSE_HEADERS = "HtmlServlet.customResponseHeaders";
public static final String OBJECT_RESOLUTION_PROPERTIES = "HtmlServlet.resolveProperties";
private static final List<String> customResponseHeaders = new LinkedList<>();
private static final ThreadLocalMatcher threadLocalUUIDMatcher = new ThreadLocalMatcher("[a-fA-F0-9]{32}");
private static final ExecutorService threadPool = Executors.newCachedThreadPool();
private final Pattern FilenameCleanerPattern = Pattern.compile("[\n\r]", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL);
private final StructrHttpServiceConfig config = new StructrHttpServiceConfig();
private final Set<String> possiblePropertyNamesForEntityResolving = new LinkedHashSet<>();
private boolean isAsync = false;
@Override
public StructrHttpServiceConfig getConfig() {
return config;
}
public HtmlServlet() {
final String customResponseHeadersString = Settings.HtmlCustomResponseHeaders.getValue();
if (StringUtils.isNotBlank(customResponseHeadersString)) {
customResponseHeaders.addAll(Arrays.asList(customResponseHeadersString.split("[ ,]+")));
}
// resolving properties
final String resolvePropertiesSource = Settings.HtmlResolveProperties.getValue();
for (final String src : resolvePropertiesSource.split("[, ]+")) {
final String name = src.trim();
if (StringUtils.isNotBlank(name)) {
possiblePropertyNamesForEntityResolving.add(name);
}
}
this.isAsync = Settings.Async.getValue();
}
@Override
public void destroy() {
}
@Override
protected void doGet(final HttpServletRequest request, final HttpServletResponse response) {
final Authenticator auth = getConfig().getAuthenticator();
List<Page> pages = null;
boolean requestUriContainsUuids = false;
SecurityContext securityContext;
final App app;
try {
assertInitialized();
final String path = request.getPathInfo();
// check for registration (has its own tx because of write access
if (checkRegistration(auth, request, response, path)) {
return;
}
// check for registration (has its own tx because of write access
if (checkResetPassword(auth, request, response, path)) {
return;
}
// isolate request authentication in a transaction
try (final Tx tx = StructrApp.getInstance().tx()) {
securityContext = auth.initializeAndExamineRequest(request, response);
tx.success();
}
app = StructrApp.getInstance(securityContext);
try (final Tx tx = app.tx()) {
// Ensure access mode is frontend
securityContext.setAccessMode(AccessMode.Frontend);
request.setCharacterEncoding("UTF-8");
// Important: Set character encoding before calling response.getWriter() !!, see Servlet Spec 5.4
response.setCharacterEncoding("UTF-8");
boolean dontCache = false;
logger.debug("Path info {}", path);
// don't continue on redirects
if (response.getStatus() == 302) {
tx.success();
return;
}
final Principal user = securityContext.getUser(false);
if (user != null) {
// Don't cache if a user is logged in
dontCache = true;
}
final RenderContext renderContext = RenderContext.getInstance(securityContext, request, response);
renderContext.setResourceProvider(config.getResourceProvider());
final EditMode edit = renderContext.getEditMode(user);
DOMNode rootElement = null;
AbstractNode dataNode = null;
final String[] uriParts = PathHelper.getParts(path);
if ((uriParts == null) || (uriParts.length == 0)) {
// find a visible page
rootElement = findIndexPage(securityContext, pages, edit);
logger.debug("No path supplied, trying to find index page");
} else {
if (rootElement == null) {
rootElement = findPage(securityContext, pages, path, edit);
} else {
dontCache = true;
}
}
if (rootElement == null) { // No page found
// In case of a file, try to find a file with the query string in the filename
final String queryString = request.getQueryString();
// Look for a file, first include the query string
FileBase file = findFile(securityContext, request, path + (queryString != null ? "?" + queryString : ""));
// If no file with query string in the file name found, try without query string
if (file == null) {
file = findFile(securityContext, request, path);
}
if (file != null) {
streamFile(securityContext, file, request, response, edit);
tx.success();
return;
}
// store remaining path parts in request
final Matcher matcher = threadLocalUUIDMatcher.get();
for (int i = 0; i < uriParts.length; i++) {
request.setAttribute(uriParts[i], i);
matcher.reset(uriParts[i]);
// set to "true" if part matches UUID pattern
requestUriContainsUuids |= matcher.matches();
}
if (!requestUriContainsUuids) {
// Try to find a data node by name
dataNode = findFirstNodeByName(securityContext, request, path);
} else {
dataNode = findNodeByUuid(securityContext, PathHelper.getName(path));
}
//if (dataNode != null && !(dataNode instanceof Linkable)) {
if (dataNode != null) {
// Last path part matches a data node
// Remove last path part and try again searching for a page
// clear possible entry points
request.removeAttribute(POSSIBLE_ENTRY_POINTS_KEY);
rootElement = findPage(securityContext, pages, StringUtils.substringBeforeLast(path, PathHelper.PATH_SEP), edit);
renderContext.setDetailsDataObject(dataNode);
// Start rendering on data node
if (rootElement == null && dataNode instanceof DOMNode) {
// check visibleForSite here as well
if (!(dataNode instanceof Page) || isVisibleForSite(request, (Page)dataNode)) {
rootElement = ((DOMNode) dataNode);
}
}
}
}
// look for pages with HTTP Basic Authentication (must be done as superuser)
if (rootElement == null) {
final HttpBasicAuthResult authResult = checkHttpBasicAuth(request, response, path);
switch (authResult.authState()) {
// Element with Basic Auth found and authentication succeeded
case Authenticated:
final Linkable result = authResult.getRootElement();
if (result instanceof Page) {
rootElement = (DOMNode)result;
securityContext = authResult.getSecurityContext();
renderContext.pushSecurityContext(securityContext);
} else if (result instanceof FileBase) {
streamFile(authResult.getSecurityContext(), (FileBase)result, request, response, EditMode.NONE);
tx.success();
return;
}
break;
// Page with Basic Auth found but not yet authenticated
case MustAuthenticate:
final Page errorPage = StructrApp.getInstance().nodeQuery(Page.class).and(Page.showOnErrorCodes, "401", false).getFirst();
if (errorPage != null && isVisibleForSite(request, errorPage)) {
// set error page
rootElement = errorPage;
// don't cache the error page
dontCache = true;
} else {
// send error
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
tx.success();
return;
}
break;
// no Basic Auth for given path, go on
case NoBasicAuth:
break;
}
}
// Still nothing found, do error handling
if (rootElement == null) {
rootElement = notFound(response, securityContext);
}
if (rootElement == null) {
tx.success();
return;
}
// check dont cache flag on page (if root element is a page)
// but don't modify true to false
dontCache |= rootElement.getProperty(Page.dontCache);
if (EditMode.WIDGET.equals(edit) || dontCache) {
setNoCacheHeaders(response);
}
if (!securityContext.isVisible(rootElement)) {
rootElement = notFound(response, securityContext);
if (rootElement == null) {
tx.success();
return;
}
} else {
if (!EditMode.WIDGET.equals(edit) && !dontCache && notModifiedSince(request, response, rootElement, dontCache)) {
ServletOutputStream out = response.getOutputStream();
out.flush();
//response.flushBuffer();
out.close();
} else {
// prepare response
response.setCharacterEncoding("UTF-8");
String contentType = rootElement.getProperty(Page.contentType);
if (contentType == null) {
// Default
contentType = "text/html;charset=UTF-8";
}
if (contentType.equals("text/html")) {
contentType = contentType.concat(";charset=UTF-8");
}
response.setContentType(contentType);
setCustomResponseHeaders(response);
final boolean createsRawData = rootElement.getProperty(Page.pageCreatesRawData);
// async or not?
if (isAsync && !createsRawData) {
final AsyncContext async = request.startAsync();
final ServletOutputStream out = async.getResponse().getOutputStream();
final AtomicBoolean finished = new AtomicBoolean(false);
final DOMNode rootNode = rootElement;
threadPool.submit(new Runnable() {
@Override
public void run() {
try (final Tx tx = app.tx()) {
// render
rootNode.render(renderContext, 0);
finished.set(true);
tx.success();
} catch (Throwable t) {
t.printStackTrace();
logger.warn("Error while rendering page {}: {}", rootNode.getName(), t.getMessage());
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
finished.set(true);
} catch (IOException ex) {
logger.warn("", ex);
}
}
}
});
// start output write listener
out.setWriteListener(new WriteListener() {
@Override
public void onWritePossible() throws IOException {
try {
final Queue<String> queue = renderContext.getBuffer().getQueue();
while (out.isReady()) {
String buffer = null;
synchronized (queue) {
buffer = queue.poll();
}
if (buffer != null) {
out.print(buffer);
} else {
if (finished.get()) {
async.complete();
// prevent this block from being called again
break;
}
Thread.sleep(1);
}
}
} catch (Throwable t) {
logger.warn("", t);
}
}
@Override
public void onError(Throwable t) {
logger.warn("", t);
}
});
} else {
final StringRenderBuffer buffer = new StringRenderBuffer();
renderContext.setBuffer(buffer);
// render
rootElement.render(renderContext, 0);
try {
response.getOutputStream().write(buffer.getBuffer().toString().getBytes("utf-8"));
response.getOutputStream().flush();
response.getOutputStream().close();
} catch (IOException ioex) {
logger.warn("", ioex);
}
}
}
}
tx.success();
} catch (FrameworkException fex) {
logger.error("Exception while processing request", fex);
}
} catch (IOException | FrameworkException t) {
logger.error("Exception while processing request", t);
UiAuthenticator.writeInternalServerError(response);
}
}
@Override
protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException {
doGet(request, response);
}
@Override
protected void doHead(final HttpServletRequest request, final HttpServletResponse response) {
final Authenticator auth = getConfig().getAuthenticator();
SecurityContext securityContext;
List<Page> pages = null;
boolean requestUriContainsUuids = false;
final App app;
try {
assertInitialized();
String path = request.getPathInfo();
// isolate request authentication in a transaction
try (final Tx tx = StructrApp.getInstance().tx()) {
securityContext = auth.initializeAndExamineRequest(request, response);
tx.success();
}
app = StructrApp.getInstance(securityContext);
try (final Tx tx = app.tx()) {
// Ensure access mode is frontend
securityContext.setAccessMode(AccessMode.Frontend);
request.setCharacterEncoding("UTF-8");
// Important: Set character encoding before calling response.getWriter() !!, see Servlet Spec 5.4
response.setCharacterEncoding("UTF-8");
response.setContentLength(0);
boolean dontCache = false;
logger.debug("Path info {}", path);
// don't continue on redirects
if (response.getStatus() == 302) {
tx.success();
return;
}
final Principal user = securityContext.getUser(false);
if (user != null) {
// Don't cache if a user is logged in
dontCache = true;
}
final RenderContext renderContext = RenderContext.getInstance(securityContext, request, response);
renderContext.setResourceProvider(config.getResourceProvider());
final EditMode edit = renderContext.getEditMode(user);
DOMNode rootElement = null;
AbstractNode dataNode = null;
String[] uriParts = PathHelper.getParts(path);
if ((uriParts == null) || (uriParts.length == 0)) {
// find a visible page
rootElement = findIndexPage(securityContext, pages, edit);
logger.debug("No path supplied, trying to find index page");
} else {
if (rootElement == null) {
rootElement = findPage(securityContext, pages, path, edit);
} else {
dontCache = true;
}
}
if (rootElement == null) { // No page found
// Look for a file
FileBase file = findFile(securityContext, request, path);
if (file != null) {
//streamFile(securityContext, file, request, response, edit);
tx.success();
return;
}
// store remaining path parts in request
Matcher matcher = threadLocalUUIDMatcher.get();
for (int i = 0; i < uriParts.length; i++) {
request.setAttribute(uriParts[i], i);
matcher.reset(uriParts[i]);
// set to "true" if part matches UUID pattern
requestUriContainsUuids |= matcher.matches();
}
if (!requestUriContainsUuids) {
// Try to find a data node by name
dataNode = findFirstNodeByName(securityContext, request, path);
} else {
dataNode = findNodeByUuid(securityContext, PathHelper.getName(path));
}
if (dataNode != null && !(dataNode instanceof Linkable)) {
// Last path part matches a data node
// Remove last path part and try again searching for a page
// clear possible entry points
request.removeAttribute(POSSIBLE_ENTRY_POINTS_KEY);
rootElement = findPage(securityContext, pages, StringUtils.substringBeforeLast(path, PathHelper.PATH_SEP), edit);
renderContext.setDetailsDataObject(dataNode);
// Start rendering on data node
if (rootElement == null && dataNode instanceof DOMNode) {
rootElement = ((DOMNode) dataNode);
}
}
}
// look for pages with HTTP Basic Authentication (must be done as superuser)
if (rootElement == null) {
final HttpBasicAuthResult authResult = checkHttpBasicAuth(request, response, path);
switch (authResult.authState()) {
// Element with Basic Auth found and authentication succeeded
case Authenticated:
final Linkable result = authResult.getRootElement();
if (result instanceof Page) {
rootElement = (DOMNode)result;
renderContext.pushSecurityContext(authResult.getSecurityContext());
} else if (result instanceof FileBase) {
//streamFile(authResult.getSecurityContext(), (File)result, request, response, EditMode.NONE);
tx.success();
return;
}
break;
// Page with Basic Auth found but not yet authenticated
case MustAuthenticate:
tx.success();
return;
// no Basic Auth for given path, go on
case NoBasicAuth:
break;
}
}
// Still nothing found, do error handling
if (rootElement == null) {
// Check if security context has set an 401 status
if (response.getStatus() == HttpServletResponse.SC_UNAUTHORIZED) {
try {
UiAuthenticator.writeUnauthorized(response);
} catch (IllegalStateException ise) {
}
} else {
rootElement = notFound(response, securityContext);
}
}
if (rootElement == null) {
// no content
response.setContentLength(0);
response.getOutputStream().close();
tx.success();
return;
}
// check dont cache flag on page (if root element is a page)
// but don't modify true to false
dontCache |= rootElement.getProperty(Page.dontCache);
if (EditMode.WIDGET.equals(edit) || dontCache) {
setNoCacheHeaders(response);
}
if (!securityContext.isVisible(rootElement)) {
rootElement = notFound(response, securityContext);
if (rootElement == null) {
tx.success();
return;
}
}
if (securityContext.isVisible(rootElement)) {
if (!EditMode.WIDGET.equals(edit) && !dontCache && notModifiedSince(request, response, rootElement, dontCache)) {
response.getOutputStream().close();
} else {
// prepare response
response.setCharacterEncoding("UTF-8");
String contentType = rootElement.getProperty(Page.contentType);
if (contentType == null) {
// Default
contentType = "text/html;charset=UTF-8";
}
if (contentType.equals("text/html")) {
contentType = contentType.concat(";charset=UTF-8");
}
response.setContentType(contentType);
setCustomResponseHeaders(response);
response.getOutputStream().close();
}
} else {
notFound(response, securityContext);
response.getOutputStream().close();
}
tx.success();
} catch (Throwable fex) {
logger.error("Exception while processing request", fex);
}
} catch (FrameworkException t) {
logger.error("Exception while processing request", t);
UiAuthenticator.writeInternalServerError(response);
}
}
@Override
protected void doOptions(final HttpServletRequest request, final HttpServletResponse response) {
final Authenticator auth = config.getAuthenticator();
try {
assertInitialized();
// isolate request authentication in a transaction
try (final Tx tx = StructrApp.getInstance().tx()) {
auth.initializeAndExamineRequest(request, response);
tx.success();
}
response.setContentLength(0);
response.setHeader("Allow", "GET,HEAD,OPTIONS");
} catch (FrameworkException t) {
logger.error("Exception while processing request", t);
UiAuthenticator.writeInternalServerError(response);
}
}
/**
* Handle 404 Not Found
*
* First, search the first page which handles the 404.
*
* If none found, issue the container's 404 error.
*
* @param response
* @param securityContext
* @param renderContext
* @throws IOException
* @throws FrameworkException
*/
private Page notFound(final HttpServletResponse response, final SecurityContext securityContext) throws IOException, FrameworkException {
final List<Page> errorPages = StructrApp.getInstance(securityContext).nodeQuery(Page.class).and(Page.showOnErrorCodes, "404", false).getAsList();
for (final Page errorPage : errorPages) {
if (isVisibleForSite(securityContext.getRequest(), errorPage)) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return errorPage;
}
}
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
/**
* Find first node whose name matches the last part of the given path
*
* @param securityContext
* @param request
* @param path
* @return node
* @throws FrameworkException
*/
private AbstractNode findFirstNodeByName(final SecurityContext securityContext, final HttpServletRequest request, final String path) throws FrameworkException {
final String name = PathHelper.getName(path);
if (!name.isEmpty()) {
logger.debug("Requested name: {}", name);
final Query query = StructrApp.getInstance(securityContext).nodeQuery();
final ConfigurationProvider config = StructrApp.getConfiguration();
if (!possiblePropertyNamesForEntityResolving.isEmpty()) {
query.and();
resolvePossiblePropertyNamesForObjectResolution(config, query, name);
query.parent();
}
final Result results = query.getResult();
logger.debug("{} results", results.size());
request.setAttribute(POSSIBLE_ENTRY_POINTS_KEY, results.getResults());
return (results.size() > 0 ? (AbstractNode) results.get(0) : null);
}
return null;
}
/**
* Find node by uuid
*
* @param securityContext
* @param request
* @param uuid
* @return node
* @throws FrameworkException
*/
private AbstractNode findNodeByUuid(final SecurityContext securityContext, final String uuid) throws FrameworkException {
if (!uuid.isEmpty()) {
logger.debug("Requested id: {}", uuid);
return (AbstractNode) StructrApp.getInstance(securityContext).getNodeById(uuid);
}
return null;
}
/**
* Find a file with its name matching last path part
*
* @param securityContext
* @param request
* @param path
* @return file
* @throws FrameworkException
*/
private FileBase findFile(final SecurityContext securityContext, final HttpServletRequest request, final String path) throws FrameworkException {
List<Linkable> entryPoints = findPossibleEntryPoints(securityContext, request, path);
// If no results were found, try to replace whitespace by '+' or '%20'
if (entryPoints.isEmpty()) {
entryPoints = findPossibleEntryPoints(securityContext, request, PathHelper.replaceWhitespaceByPlus(path));
}
if (entryPoints.isEmpty()) {
entryPoints = findPossibleEntryPoints(securityContext, request, PathHelper.replaceWhitespaceByPercentTwenty(path));
}
for (Linkable node : entryPoints) {
if (node instanceof FileBase && (path.equals(node.getPath()) || node.getUuid().equals(PathHelper.getName(path)))) {
return (FileBase) node;
}
}
return null;
}
/**
* Find a page with matching path.
*
* To be compatible with older versions, fallback to name-only lookup.
*
* @param securityContext
* @param pages
* @param path
* @param edit
* @return page
* @throws FrameworkException
*/
private Page findPage(final SecurityContext securityContext, List<Page> pages, final String path, final EditMode edit) throws FrameworkException {
if (pages == null) {
pages = StructrApp.getInstance(securityContext).nodeQuery(Page.class).getAsList();
Collections.sort(pages, new GraphObjectComparator(Page.position, GraphObjectComparator.ASCENDING));
}
for (final Page page : pages) {
final String pagePath = page.getPath();
if (pagePath != null && pagePath.equals(path) && (EditMode.CONTENT.equals(edit) || isVisibleForSite(securityContext.getRequest(), page))) {
return page;
}
}
final String name = PathHelper.getName(path);
for (final Page page : pages) {
final String pageName = page.getName();
if (pageName != null && pageName.equals(name) && (EditMode.CONTENT.equals(edit) || isVisibleForSite(securityContext.getRequest(), page))) {
return page;
}
}
for (final Page page : pages) {
final String pageUuid = page.getUuid();
if (pageUuid != null && pageUuid.equals(name) && (EditMode.CONTENT.equals(edit) || isVisibleForSite(securityContext.getRequest(), page))) {
return page;
}
}
return null;
}
/**
* Find the page with the lowest non-empty position value which is visible in the
* current security context and for the given site.
*
* @param securityContext
* @param pages
* @param edit
* @return page
* @throws FrameworkException
*/
private Page findIndexPage(final SecurityContext securityContext, List<Page> pages, final EditMode edit) throws FrameworkException {
if (pages == null) {
pages = StructrApp.getInstance(securityContext).nodeQuery(Page.class).getAsList();
Collections.sort(pages, new GraphObjectComparator(Page.position, GraphObjectComparator.ASCENDING));
}
for (Page page : pages) {
if (securityContext.isVisible(page) && page.getProperty(Page.position) != null && ((EditMode.CONTENT.equals(edit) || isVisibleForSite(securityContext.getRequest(), page)) || (page.getProperty(Page.enableBasicAuth) && page.getProperty(Page.visibleToAuthenticatedUsers)))) {
return page;
}
}
return null;
}
/**
* This method checks if the current request is a user registration
* confirmation, usually triggered by a user clicking on a confirmation
* link in an e-mail.
*
* @param request
* @param response
* @param path
* @return true if the registration was successful
* @throws FrameworkException
* @throws IOException
*/
private boolean checkRegistration(final Authenticator auth, final HttpServletRequest request, final HttpServletResponse response, final String path) throws FrameworkException, IOException {
logger.debug("Checking registration ...");
String key = request.getParameter(CONFIRM_KEY_KEY);
if (StringUtils.isEmpty(key)) {
return false;
}
final String targetPage = request.getParameter(TARGET_PAGE_KEY);
final String errorPage = request.getParameter(ERROR_PAGE_KEY);
if (CONFIRM_REGISTRATION_PAGE.equals(path)) {
final App app = StructrApp.getInstance();
Result<Principal> results;
try (final Tx tx = app.tx()) {
results = app.nodeQuery(Principal.class).and(User.confirmationKey, key).getResult();
tx.success();
}
if (!results.isEmpty()) {
final Principal user = results.get(0);
try (final Tx tx = app.tx()) {
// Clear confirmation key and set session id
user.setProperties(user.getSecurityContext(), new PropertyMap(User.confirmationKey, null));
if (Settings.RestUserAutologin.getValue()) {
AuthHelper.doLogin(request, user);
}
tx.success();
}
// Redirect to target page
if (StringUtils.isNotBlank(targetPage)) {
response.sendRedirect("/" + targetPage);
}
return true;
} else {
// Redirect to error page
if (StringUtils.isNotBlank(errorPage)) {
response.sendRedirect("/" + errorPage);
}
return true;
}
}
return false;
}
/**
* This method checks if the current request to reset a user password
*
* @param request
* @param response
* @param path
* @return true if the registration was successful
* @throws FrameworkException
* @throws IOException
*/
private boolean checkResetPassword(final Authenticator auth, final HttpServletRequest request, final HttpServletResponse response, final String path) throws FrameworkException, IOException {
logger.debug("Checking registration ...");
String key = request.getParameter(CONFIRM_KEY_KEY);
if (StringUtils.isEmpty(key)) {
return false;
}
final String targetPage = request.getParameter(TARGET_PAGE_KEY);
if (RESET_PASSWORD_PAGE.equals(path)) {
final App app = StructrApp.getInstance();
Result<Principal> results;
try (final Tx tx = app.tx()) {
results = app.nodeQuery(Principal.class).and(User.confirmationKey, key).getResult();
tx.success();
}
if (!results.isEmpty()) {
final Principal user = results.get(0);
try (final Tx tx = app.tx()) {
// Clear confirmation key and set session id
user.setProperties(user.getSecurityContext(), new PropertyMap(User.confirmationKey, null));
if (Settings.RestUserAutologin.getValue()) {
AuthHelper.doLogin(request, user);
}
tx.success();
}
}
// Redirect to target page
if (StringUtils.isNotBlank(targetPage)) {
response.sendRedirect(targetPage);
}
return true;
}
return false;
}
private List<Linkable> findPossibleEntryPointsByUuid(final SecurityContext securityContext, final HttpServletRequest request, final String uuid) throws FrameworkException {
final List<Linkable> possibleEntryPoints = (List<Linkable>) request.getAttribute(POSSIBLE_ENTRY_POINTS_KEY);
if (CollectionUtils.isNotEmpty(possibleEntryPoints)) {
return possibleEntryPoints;
}
if (uuid.length() > 0) {
logger.debug("Requested id: {}", uuid);
final Query query = StructrApp.getInstance(securityContext).nodeQuery();
query.and(GraphObject.id, uuid);
query.and().orType(Page.class).orTypes(File.class);
// Searching for pages needs super user context anyway
Result results = query.getResult();
logger.debug("{} results", results.size());
request.setAttribute(POSSIBLE_ENTRY_POINTS_KEY, results.getResults());
return (List<Linkable>) results.getResults();
}
return Collections.EMPTY_LIST;
}
private List<Linkable> findPossibleEntryPointsByPath(final SecurityContext securityContext, final HttpServletRequest request, final String path) throws FrameworkException {
final List<Linkable> possibleEntryPoints = (List<Linkable>) request.getAttribute(POSSIBLE_ENTRY_POINTS_KEY);
if (CollectionUtils.isNotEmpty(possibleEntryPoints)) {
return possibleEntryPoints;
}
if (path.length() > 0) {
logger.debug("Requested path: {}", path);
final Query pageQuery = StructrApp.getInstance(securityContext).nodeQuery();
pageQuery.and(Page.path, path).andType(Page.class);
final Result pages = pageQuery.getResult();
final Query fileQuery = StructrApp.getInstance(securityContext).nodeQuery();
fileQuery.and(AbstractFile.path, path).andTypes(File.class);
final Result files = fileQuery.getResult();
logger.debug("Found {} pages and {} files/folders", new Object[] { pages.size(), files.size() });
final List<Linkable> linkables = (List<Linkable>) pages.getResults();
linkables.addAll(files.getResults());
request.setAttribute(POSSIBLE_ENTRY_POINTS_KEY, linkables);
return linkables;
}
return Collections.EMPTY_LIST;
}
private List<Linkable> findPossibleEntryPoints(final SecurityContext securityContext, final HttpServletRequest request, final String path) throws FrameworkException {
List<Linkable> possibleEntryPoints = (List<Linkable>) request.getAttribute(POSSIBLE_ENTRY_POINTS_KEY);
if (CollectionUtils.isNotEmpty(possibleEntryPoints)) {
return possibleEntryPoints;
}
final int numberOfParts = PathHelper.getParts(path).length;
if (numberOfParts > 0) {
logger.debug("Requested name {}", path);
possibleEntryPoints = findPossibleEntryPointsByPath(securityContext, request, path);
if (possibleEntryPoints.isEmpty() && numberOfParts == 1) {
possibleEntryPoints = findPossibleEntryPointsByUuid(securityContext, request, PathHelper.getName(path));
}
return possibleEntryPoints;
}
return Collections.EMPTY_LIST;
}
//~--- set methods ----------------------------------------------------
public static void setNoCacheHeaders(final HttpServletResponse response) {
response.setHeader("Cache-Control", "private, max-age=0, s-maxage=0, no-cache, no-store, must-revalidate"); // HTTP 1.1.
response.setHeader("Pragma", "no-cache, no-store"); // HTTP 1.0.
response.setDateHeader("Expires", 0);
}
private static void setCustomResponseHeaders(final HttpServletResponse response) {
for (final String header : customResponseHeaders) {
final String[] keyValuePair = header.split("[ :]+");
response.setHeader(keyValuePair[0], keyValuePair[1]);
logger.debug("Set custom response header: {} {}", new Object[]{keyValuePair[0], keyValuePair[1]});
}
}
private static boolean notModifiedSince(final HttpServletRequest request, HttpServletResponse response, final AbstractNode node, final boolean dontCache) {
boolean notModified = false;
final Date lastModified = node.getLastModifiedDate();
// add some caching directives to header
// see http://weblogs.java.net/blog/2007/08/08/expires-http-header-magic-number-yslow
final DateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
httpDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
response.setHeader("Date", httpDateFormat.format(new Date()));
final Calendar cal = new GregorianCalendar();
final Integer seconds = node.getProperty(Page.cacheForSeconds);
if (!dontCache && seconds != null) {
cal.add(Calendar.SECOND, seconds);
response.setHeader("Cache-Control", "max-age=" + seconds + ", s-maxage=" + seconds + "");
response.setHeader("Expires", httpDateFormat.format(cal.getTime()));
} else {
if (!dontCache) {
response.setHeader("Cache-Control", "no-cache, must-revalidate, proxy-revalidate");
} else {
response.setHeader("Cache-Control", "private, no-cache, no-store, max-age=0, s-maxage=0, must-revalidate, proxy-revalidate");
}
}
if (lastModified != null) {
final Date roundedLastModified = DateUtils.round(lastModified, Calendar.SECOND);
response.setHeader("Last-Modified", httpDateFormat.format(roundedLastModified));
final String ifModifiedSince = request.getHeader("If-Modified-Since");
if (StringUtils.isNotBlank(ifModifiedSince)) {
try {
Date ifModSince = httpDateFormat.parse(ifModifiedSince);
// Note that ifModSince has not ms resolution, so the last digits are always 000
// That requires the lastModified to be rounded to seconds
if ((ifModSince != null) && (roundedLastModified.equals(ifModSince) || roundedLastModified.before(ifModSince))) {
notModified = true;
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
response.setHeader("Vary", "Accept-Encoding");
}
} catch (ParseException ex) {
logger.warn("Could not parse If-Modified-Since header", ex);
}
}
}
return notModified;
}
private void streamFile(SecurityContext securityContext, final FileBase file, HttpServletRequest request, HttpServletResponse response, final EditMode edit) throws IOException {
if (!securityContext.isVisible(file)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
final ServletOutputStream out = response.getOutputStream();
final String downloadAsFilename = request.getParameter(DOWNLOAD_AS_FILENAME_KEY);
final Map<String, Object> callbackMap = new LinkedHashMap<>();
// make edit mode available in callback method
callbackMap.put("editMode", edit);
if (downloadAsFilename != null) {
// remove any CR LF characters from the filename to prevent Header Splitting attacks
final String cleanedFilename = FilenameCleanerPattern.matcher(downloadAsFilename).replaceAll("");
// Set Content-Disposition header to suggest a default filename and force a "save-as" dialog
// See:
// http://en.wikipedia.org/wiki/MIME#Content-Disposition,
// http://tools.ietf.org/html/rfc2183
// http://tools.ietf.org/html/rfc1806
// http://tools.ietf.org/html/rfc2616#section-15.5 and http://tools.ietf.org/html/rfc2616#section-19.5.1
response.addHeader("Content-Disposition", "attachment; filename=\"" + cleanedFilename + "\"");
callbackMap.put("requestedFileName", downloadAsFilename);
}
if (!EditMode.WIDGET.equals(edit) && notModifiedSince(request, response, file, false)) {
out.flush();
out.close();
callbackMap.put("statusCode", HttpServletResponse.SC_NOT_MODIFIED);
} else {
final String downloadAsDataUrl = request.getParameter(DOWNLOAD_AS_DATA_URL_KEY);
if (downloadAsDataUrl != null) {
IOUtils.write(FileHelper.getBase64String(file), out);
response.setContentType("text/plain");
response.setStatus(HttpServletResponse.SC_OK);
out.flush();
out.close();
callbackMap.put("statusCode", HttpServletResponse.SC_OK);
} else {
// 2b: stream file to response
final InputStream in = file.getInputStream();
final String contentType = file.getContentType();
if (contentType != null) {
response.setContentType(contentType);
} else {
// Default
response.setContentType("application/octet-stream");
}
final String range = request.getHeader("Range");
try {
if (StringUtils.isNotEmpty(range)) {
final long len = file.getSize();
long start = 0;
long end = len - 1;
final Matcher matcher = Pattern.compile("bytes=(?<start>\\d*)-(?<end>\\d*)").matcher(range);
if (matcher.matches()) {
String startGroup = matcher.group("start");
start = startGroup.isEmpty() ? start : Long.valueOf(startGroup);
start = Math.max(0, start);
String endGroup = matcher.group("end");
end = endGroup.isEmpty() ? end : Long.valueOf(endGroup);
end = end > len - 1 ? len - 1 : end;
}
long contentLength = end - start + 1;
// Tell the client that we support byte ranges
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Range", String.format("bytes %s-%s/%s", start, end, len));
response.setHeader("Content-Length", String.format("%s", contentLength));
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
callbackMap.put("statusCode", HttpServletResponse.SC_PARTIAL_CONTENT);
IOUtils.copyLarge(in, out, start, contentLength);
} else {
response.setStatus(HttpServletResponse.SC_OK);
callbackMap.put("statusCode", HttpServletResponse.SC_OK);
IOUtils.copyLarge(in, out);
}
} catch (Throwable t) {
} finally {
if (out != null) {
try {
// 3: output content
out.flush();
out.close();
} catch (Throwable t) {
}
}
if (in != null) {
in.close();
}
response.setStatus(HttpServletResponse.SC_OK);
}
}
}
// WIDGET mode means "opened in frontend", which we don't want to count as an external download
if (!EditMode.WIDGET.equals(edit)) {
// call onDownload callback
try {
file.invokeMethod("onDownload", callbackMap, false);
} catch (FrameworkException fex) {
logger.warn("", fex);
}
}
}
/**
* Check if the given page is visible for the requested site defined by
* a hostname and a port.
*
* @param request
* @param page
* @return
*/
private boolean isVisibleForSite(final HttpServletRequest request, final Page page) {
logger.debug("Page: {} [{}], server name: {}, server port: {}", new Object[]{page.getName(), page.getUuid(), request.getServerName(), request.getServerPort()});
final Site site = page.getProperty(Page.site);
if (site == null) {
logger.debug("Page {} [{}] has no site assigned.", new Object[]{page.getName(), page.getUuid()});
return true;
}
logger.debug("Checking site: {} [{}], hostname: {}, port: {}", new Object[]{site.getName(), site.getUuid(), site.getProperty(Site.hostname), site.getProperty(Site.port)});
final String serverName = request.getServerName();
final int serverPort = request.getServerPort();
if (StringUtils.isNotBlank(serverName) && !serverName.equals(site.getProperty(Site.hostname))) {
logger.debug("Server name {} does not fit site hostname {}", new Object[]{serverName, site.getProperty(Site.hostname)});
return false;
}
final Integer sitePort = site.getProperty(Site.port);
if (sitePort != null && serverPort != sitePort) {
logger.debug("Server port {} does not match site port {}", new Object[]{serverPort, sitePort});
return false;
}
logger.debug("Matching site: {} [{}], hostname: {}, port: {}", new Object[]{site.getName(), site.getUuid(), site.getProperty(Site.hostname), site.getProperty(Site.port)});
return true;
}
private void resolvePossiblePropertyNamesForObjectResolution(final ConfigurationProvider config, final Query query, final String name) {
for (final String possiblePropertyName : possiblePropertyNamesForEntityResolving) {
final String[] parts = possiblePropertyName.split("\\.");
String className = AbstractNode.class.getSimpleName();
String keyName = AbstractNode.name.jsonName();
switch (parts.length) {
case 2:
className = parts[0];
keyName = parts[1];
break;
default:
logger.warn("Unable to process key for object resolution {}.", possiblePropertyName);
break;
}
if (StringUtils.isNoneBlank(className, keyName)) {
final Class type = config.getNodeEntityClass(className);
if (type != null) {
final PropertyKey key = config.getPropertyKeyForJSONName(type, keyName, false);
if (key != null) {
try {
final PropertyConverter converter = key.inputConverter(SecurityContext.getSuperUserInstance());
if (converter != null) {
// try converted value, fail silenty
query.or(key, converter.convert(name));
} else {
// try unconverted value, fail silently if it doesn't work
query.or(key, name);
}
} catch (FrameworkException ignore) { }
} else {
logger.warn("Unable to find property key {} of type {} defined in key {} used for object resolution.", new Object[] { keyName, className, possiblePropertyName } );
}
} else {
logger.warn("Unable to find type {} defined in key {} used for object resolution.", new Object[] { className, possiblePropertyName } );
}
}
}
}
private HttpBasicAuthResult checkHttpBasicAuth(final HttpServletRequest request, final HttpServletResponse response, final String path) throws IOException, FrameworkException {
// Look for renderable objects using a SuperUserSecurityContext,
// but dont actually render the page. We're only interested in
// the authentication settings.
Linkable possiblePage = null;
// try the different methods..
if (possiblePage == null) {
possiblePage = StructrApp.getInstance().nodeQuery(Page.class).and(Page.path, path).and(Page.enableBasicAuth, true).sort(Page.position).getFirst();
}
if (possiblePage == null) {
possiblePage = StructrApp.getInstance().nodeQuery(Page.class).and(Page.name, PathHelper.getName(path)).and(Page.enableBasicAuth, true).sort(Page.position).getFirst();
}
if (possiblePage == null) {
possiblePage = StructrApp.getInstance().nodeQuery(File.class).and(File.path, path).and(File.enableBasicAuth, true).getFirst();
}
if (possiblePage == null) {
possiblePage = StructrApp.getInstance().nodeQuery(File.class).and(File.name, PathHelper.getName(path)).and(File.enableBasicAuth, true).getFirst();
}
if (possiblePage != null) {
String realm = possiblePage.getProperty(Page.basicAuthRealm);
if (realm == null) {
realm = possiblePage.getName();
}
// check Http Basic Authentication headers
final Principal principal = getPrincipalForAuthorizationHeader(request.getHeader("Authorization"));
if (principal != null) {
final SecurityContext securityContext = SecurityContext.getInstance(principal, AccessMode.Frontend);
if (securityContext != null) {
// find and instantiate the page again so that the SuperUserSecurityContext
// can not leak into any of the children of the given page. This is dangerous..
final Linkable page = StructrApp.getInstance(securityContext).get(Linkable.class, possiblePage.getUuid());
if (page != null) {
securityContext.setRequest(request);
securityContext.setResponse(response);
return new HttpBasicAuthResult(AuthState.Authenticated, securityContext, page);
}
}
}
// fallback: the following code will be executed if no Authorization
// header was sent, OR if the authentication failed
response.setHeader("WWW-Authenticate", "BASIC realm=\"" + realm + "\"");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// no Authorization header sent by client
return HttpBasicAuthResult.MUST_AUTHENTICATE;
}
// no Http Basic Auth enabled for any page
return HttpBasicAuthResult.NO_BASIC_AUTH;
}
private Principal getPrincipalForAuthorizationHeader(final String authHeader) {
if (authHeader != null) {
final String[] authParts = authHeader.split(" ");
if (authParts.length == 2) {
final String authType = authParts[0];
final String authValue = authParts[1];
String username = null;
String password = null;
if ("Basic".equals(authType)) {
final String value = new String(Base64.decode(authValue), Charset.forName("utf-8"));
final String[] parts = value.split(":");
if (parts.length == 2) {
username = parts[0];
password = parts[1];
}
}
if (StringUtils.isNoneBlank(username, password)) {
try {
return AuthHelper.getPrincipalForPassword(Principal.name, username, password);
} catch (Throwable t) {
// ignore
}
}
}
}
return null;
}
private void assertInitialized() throws FrameworkException {
if (!Services.getInstance().isInitialized()) {
throw new FrameworkException(503, "System is not initialized yet.");
}
}
// ----- nested classes -----
private enum AuthState {
NoBasicAuth, MustAuthenticate, Authenticated
}
private static class HttpBasicAuthResult {
// use singletons for the most common cases
public static final HttpBasicAuthResult MUST_AUTHENTICATE = new HttpBasicAuthResult(AuthState.MustAuthenticate);
public static final HttpBasicAuthResult NO_BASIC_AUTH = new HttpBasicAuthResult(AuthState.NoBasicAuth);
private SecurityContext securityContext = null;
private Linkable rootElement = null;
private AuthState authState = null;
public HttpBasicAuthResult(final AuthState authState) {
this(authState, null, null);
}
public HttpBasicAuthResult(final AuthState authState, final SecurityContext securityContext, final Linkable rootElement) {
this.securityContext = securityContext;
this.rootElement = rootElement;
this.authState = authState;
}
public SecurityContext getSecurityContext() {
return securityContext;
}
public AuthState authState() {
return authState;
}
public Linkable getRootElement() {
return rootElement;
}
}
}