/*
 * #%L
 * Alfresco Remote API
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco 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 Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.web.scripts.bean;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.io.Writer;
import java.net.SocketException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.query.CannedQueryPageDetails;
import org.alfresco.query.PagingRequest;
import org.alfresco.query.PagingResults;
import org.alfresco.repo.content.MimetypeMap;
import org.alfresco.repo.model.filefolder.HiddenAspect;
import org.alfresco.repo.policy.BehaviourFilter;
import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork;
import org.alfresco.repo.security.permissions.AccessDeniedException;
import org.alfresco.repo.site.SiteModel;
import org.alfresco.service.cmr.model.FileExistsException;
import org.alfresco.service.cmr.model.FileFolderService;
import org.alfresco.service.cmr.model.FileFolderUtil;
import org.alfresco.service.cmr.model.FileInfo;
import org.alfresco.service.cmr.model.FileNotFoundException;
import org.alfresco.service.cmr.repository.ChildAssociationRef;
import org.alfresco.service.cmr.repository.ContentIOException;
import org.alfresco.service.cmr.repository.ContentReader;
import org.alfresco.service.cmr.repository.ContentService;
import org.alfresco.service.cmr.repository.ContentWriter;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.security.OwnableService;
import org.alfresco.service.cmr.security.PermissionService;
import org.alfresco.service.cmr.site.SiteInfo;
import org.alfresco.service.cmr.site.SiteService;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.extensions.surf.util.URLDecoder;
import org.springframework.extensions.webscripts.Status;
import org.springframework.extensions.webscripts.WebScriptException;
import org.springframework.extensions.webscripts.WebScriptResponse;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

/**
 * ADM Remote Store service.
 * <p>
 * This implementation of the RemoteStore is tied to the current SiteService implementation.
 * <p>
 * It remaps incoming generic document path requests to the appropriate folder structure
 * in the Sites folder. Dashboard pages and component bindings are remapped to take advantage
 * of inherited permissions in the appropriate root site folder, ensuring that only valid
 * users can write to the appropriate configuration objects.
 * 
 * @see BaseRemoteStore for the available API methods.
 * 
 * @author Kevin Roast
 */
public class ADMRemoteStore extends BaseRemoteStore
{
    private static final Log logger = LogFactory.getLog(ADMRemoteStore.class);
    
    // name of the surf config folder
    private static final String SURF_CONFIG = "surf-config";

    // patterns used to match site and user specific configuration locations
    private static final String PATH_COMPONENTS = "components";
    private static final String PATH_PAGES      = "pages";
    private static final String PATH_USER       = "user";
    private static final String PATH_SITE       = "site";
    private static final String USER_CONFIG = ".*\\." + PATH_USER + "~(.*)~.*";
    private static final String USER_CONFIG_PATTERN = USER_CONFIG.replaceAll("\\.\\*", "*").replace("\\", "");
    private static final Pattern USER_PATTERN_1 = Pattern.compile(".*/" + PATH_COMPONENTS + "/" + USER_CONFIG);
    private static final Pattern USER_PATTERN_2 = Pattern.compile(".*/" + PATH_PAGES + "/" + PATH_USER + "/(.*?)(/.*)?$");
    private static final Pattern SITE_PATTERN_1 = Pattern.compile(".*/" + PATH_COMPONENTS + "/.*\\." + PATH_SITE + "~(.*)~.*");
    private static final Pattern SITE_PATTERN_2 = Pattern.compile(".*/" + PATH_PAGES + "/" + PATH_SITE + "/(.*?)(/.*)?$");
    
    
    // service beans
    protected NodeService nodeService;
    protected NodeService unprotNodeService;
    protected FileFolderService fileFolderService;
    protected NamespaceService namespaceService;
    protected SiteService siteService;
    protected ContentService contentService;
    protected HiddenAspect hiddenAspect;
    protected PermissionService permissionService;
    protected OwnableService ownableService;
    private BehaviourFilter behaviourFilter;
    
    /**
     * Date format pattern used to parse HTTP date headers in RFC 1123 format.
     */
    private static final String PATTERN_RFC1123 = "EEE, dd MMM yyyy HH:mm:ss zzz";
    private static final TimeZone GMT = TimeZone.getTimeZone("GMT");


    /**
     * @param nodeService       the NodeService to set
     */
    public void setNodeService(NodeService nodeService)
    {
        this.nodeService = nodeService;
    }

    /**
     * @param nodeService       the NodeService to set
     */
    public void setUnprotectedNodeService(NodeService nodeService)
    {
        this.unprotNodeService = nodeService;
    }

    /**
     * @param fileFolderService the FileFolderService to set
     */
    public void setFileFolderService(FileFolderService fileFolderService)
    {
        this.fileFolderService = fileFolderService;
    }

    /**
     * @param namespaceService  the NamespaceService to set
     */
    public void setNamespaceService(NamespaceService namespaceService)
    {
        this.namespaceService = namespaceService;
    }
    
    /**
     * @param siteService       the SiteService to set
     */
    public void setSiteService(SiteService siteService)
    {
        this.siteService = siteService;
    }
    
    /**
     * @param contentService    the ContentService to set
     */
    public void setContentService(ContentService contentService)
    {
        this.contentService = contentService; 
    }
    
    public void setHiddenAspect(HiddenAspect hiddenAspect)
    {
        this.hiddenAspect = hiddenAspect;
    }
    
    public void setBehaviourFilter(BehaviourFilter behaviourFilter)
    {
        this.behaviourFilter = behaviourFilter;
    }

    public void setPermissionService(PermissionService permissionService)
    {
        this.permissionService = permissionService;
    }

    public void setOwnableService(OwnableService ownableService)
    {
        this.ownableService = ownableService;
    }

    /**
     * Gets the last modified timestamp for the document.
     * <p>
     * The output will be the last modified date as a long toString().
     * 
     * @param path  document path to an existing document
     */
    @Override
    protected void lastModified(final WebScriptResponse res, final String store, final String path)
        throws IOException
    {
        AuthenticationUtil.runAs(new RunAsWork<Void>()
        {
            @SuppressWarnings("synthetic-access")
            public Void doWork() throws Exception
            {
                final String encpath = encodePath(path);
                final FileInfo fileInfo = resolveFilePath(encpath);
                if (fileInfo == null)
                {
                    throw new WebScriptException("Unable to locate file: " + encpath);
                }
                
                Writer out = res.getWriter();
                out.write(Long.toString(fileInfo.getModifiedDate().getTime()));
                out.close();
                if (logger.isDebugEnabled())
                    logger.debug("lastModified: " + Long.toString(fileInfo.getModifiedDate().getTime()));
                return null;
            }
        }, AuthenticationUtil.getSystemUserName());
    }

    /**
     * Gets a document.
     * <p>
     * The output will be the document content stream.
     * 
     * @param path  document path
     */
    @Override
    protected void getDocument(final WebScriptResponse res, final String store, final String path)
    {
        AuthenticationUtil.runAs(new RunAsWork<Void>()
        {
            @SuppressWarnings("synthetic-access")
            public Void doWork() throws Exception
            {
                final String encpath = encodePath(path);
                final FileInfo fileInfo = resolveFilePath(encpath);
                if (fileInfo == null || fileInfo.isFolder())
                {
                    res.setStatus(Status.STATUS_NOT_FOUND);
                    return null;
                }
                
                final ContentReader reader;
                try
                {
                    reader = contentService.getReader(fileInfo.getNodeRef(), ContentModel.PROP_CONTENT);
                    if (reader == null || !reader.exists())
                    {
                        throw new WebScriptException("No content found for file: " + encpath);
                    }
                    
                    // establish mimetype
                    String mimetype = reader.getMimetype();
                    if (mimetype == null || mimetype.length() == 0)
                    {
                        mimetype = MimetypeMap.MIMETYPE_BINARY;
                        int extIndex = encpath.lastIndexOf('.');
                        if (extIndex != -1)
                        {
                            String ext = encpath.substring(extIndex + 1);
                            String mt = mimetypeService.getMimetypesByExtension().get(ext);
                            if (mt != null)
                            {
                                mimetype = mt;
                            }
                        }
                    }
                    
                    // set mimetype for the content and the character encoding + length for the stream
                    res.setContentType(mimetype);
                    res.setContentEncoding(reader.getEncoding());
                    SimpleDateFormat formatter = new SimpleDateFormat(PATTERN_RFC1123, Locale.US);
                    formatter.setTimeZone(GMT);
                    res.setHeader("Last-Modified", formatter.format(fileInfo.getModifiedDate()));
                    res.setHeader("Content-Length", Long.toString(reader.getSize()));
                    
                    if (logger.isDebugEnabled())
                        logger.debug("getDocument: " + fileInfo.toString());
                    
                    // get the content and stream directly to the response output stream
                    // assuming the repository is capable of streaming in chunks, this should allow large files
                    // to be streamed directly to the browser response stream.
                    try
                    {
                        reader.getContent(res.getOutputStream());
                    }
                    catch (SocketException e1)
                    {
                        // the client cut the connection - our mission was accomplished apart from a little error message
                        if (logger.isDebugEnabled())
                            logger.debug("Client aborted stream read:\n\tnode: " + encpath + "\n\tcontent: " + reader);
                    }
                    catch (ContentIOException e2)
                    {
                        if (logger.isInfoEnabled())
                            logger.info("Client aborted stream read:\n\tnode: " + encpath + "\n\tcontent: " + reader);
                    }
                    catch (Throwable err)
                    {
                       if (err.getCause() instanceof SocketException)
                       {
                          if (logger.isDebugEnabled())
                              logger.debug("Client aborted stream read:\n\tnode: " + encpath + "\n\tcontent: " + reader);
                       }
                       else
                       {
                           if (logger.isInfoEnabled())
                               logger.info(err.getMessage());
                           res.setStatus(Status.STATUS_INTERNAL_SERVER_ERROR);
                       }
                    }
                }
                catch (AccessDeniedException ae)
                {
                    res.setStatus(Status.STATUS_UNAUTHORIZED);
                }
                return null;
            }
        }, AuthenticationUtil.getSystemUserName());
    }

    /**
     * Determines if the document exists.
     * 
     * The output will be either the string "true" or the string "false".
     * 
     * @param path  document path
     */
    @Override
    protected void hasDocument(final WebScriptResponse res, final String store, final String path) throws IOException
    {
        AuthenticationUtil.runAs(new RunAsWork<Void>()
        {
            @SuppressWarnings("synthetic-access")
            public Void doWork() throws Exception
            {
                final String encpath = encodePath(path);
                final FileInfo fileInfo = resolveFilePath(encpath);
                
                Writer out = res.getWriter();
                out.write(Boolean.toString(fileInfo != null && !fileInfo.isFolder()));
                out.close();
                if (logger.isDebugEnabled())
                    logger.debug("hasDocument: " + Boolean.toString(fileInfo != null && !fileInfo.isFolder()));
                return null;
            }
        }, AuthenticationUtil.getSystemUserName());
    }

    /**
     * Creates a document.
     * <p>
     * Create methods are user authenticated, so the creation of site config must be
     * allowed for the current user.
     * 
     * @param path          document path
     * @param content       content of the document to write
     */
    @Override
    protected void createDocument(final WebScriptResponse res, final String store, final String path, final InputStream content)
    {
        try
        {
            writeDocument(path, content);
        }
        catch (AccessDeniedException ae)
        {
            res.setStatus(Status.STATUS_UNAUTHORIZED);
            throw ae;
        }
        catch (FileExistsException feeErr)
        {
            res.setStatus(Status.STATUS_CONFLICT);
            throw feeErr;
        }
    }
    
    /**
     * Creates multiple XML documents encapsulated in a single one. 
     * 
     * @param res       WebScriptResponse
     * @param store       String
     * @param in       XML document containing multiple document contents to write
     */
    @Override
    protected void createDocuments(WebScriptResponse res, String store, InputStream in)
    {
        try
        {
            DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); 
            Document document;
            document = documentBuilder.parse(in);
            Element docEl = document.getDocumentElement();
            Transformer transformer = ADMRemoteStore.this.transformer.get();
            for (Node n = docEl.getFirstChild(); n != null; n = n.getNextSibling())
            {
                if (!(n instanceof Element))
                {
                    continue;
                }
                final String path = ((Element) n).getAttribute("path");
                
                // Turn the first element child into a document
                Document doc = documentBuilder.newDocument();
                Node child;
                for (child = n.getFirstChild(); child != null ; child=child.getNextSibling())
                {
                   if (child instanceof Element)
                   {
                       doc.appendChild(doc.importNode(child, true));
                       break;
                   }
                }
                ByteArrayOutputStream out = new ByteArrayOutputStream(512);
                transformer.transform(new DOMSource(doc), new StreamResult(out));
                out.close();
                
                writeDocument(path, new ByteArrayInputStream(out.toByteArray()));
            }
        }
        catch (AccessDeniedException ae)
        {
            res.setStatus(Status.STATUS_UNAUTHORIZED);
            throw ae;
        }
        catch (FileExistsException feeErr)
        {
            res.setStatus(Status.STATUS_CONFLICT);
            throw feeErr;
        }
        catch (Exception e)
        {
            // various annoying checked SAX/IO exceptions related to XML processing can be thrown
            // none of them should occur if the XML document is well formed
            logger.error(e);
            res.setStatus(Status.STATUS_INTERNAL_SERVER_ERROR);
            throw new AlfrescoRuntimeException(e.getMessage(), e);
        }
    }
    
    protected void writeDocument(final String path, final InputStream content)
    {
        final String encpath = encodePath(path);
        final int off = encpath.lastIndexOf('/');
        if (off != -1)
        {
            // check we actually are the user we are creating a user specific path for
            final String runAsUser = getPathRunAsUser(path);
            AuthenticationUtil.runAs(new RunAsWork<Void>()
            {
                @SuppressWarnings("synthetic-access")
                public Void doWork() throws Exception
                {
                    final FileInfo parentFolder = resolveNodePath(encpath, true, false);
                    if (parentFolder == null)
                    {
                        throw new IllegalStateException("Unable to aquire parent folder reference for path: " + path);
                    }
                    
                    // ALF-17729 / ALF-17796 - disable auditable on parent folder
                    NodeRef parentFolderRef = parentFolder.getNodeRef();
                    behaviourFilter.disableBehaviour(parentFolderRef, ContentModel.ASPECT_AUDITABLE);
                    
                    try
                    {
                        final String name = encpath.substring(off + 1);
                        // existence check - convert to an UPDATE - could occur if multiple threads request
                        // a write to the same document - a valid possibility but rare
                        if (nodeService.getChildByName(parentFolderRef, ContentModel.ASSOC_CONTAINS, name) == null)
                        {
                            FileInfo fileInfo = fileFolderService.create(
                                    parentFolderRef, name, ContentModel.TYPE_CONTENT);
                            final NodeRef nodeRef = fileInfo.getNodeRef();
                            // MNT-16371: Revoke ownership privileges for surf-config folder contents, to tighten access for former SiteManagers.
                            ownableService.setOwner(nodeRef, AuthenticationUtil.getAdminUserName());

                            Map<QName, Serializable> aspectProperties = new HashMap<QName, Serializable>(1, 1.0f);
                            aspectProperties.put(ContentModel.PROP_IS_INDEXED, false);
                            unprotNodeService.addAspect(nodeRef, ContentModel.ASPECT_INDEX_CONTROL, aspectProperties);
                            ContentWriter writer = contentService.getWriter(nodeRef, ContentModel.PROP_CONTENT, true);
                            writer.guessMimetype(fileInfo.getName());
                            writer.putContent(content);
                            if (logger.isDebugEnabled())
                                logger.debug("createDocument: " + fileInfo.toString());
                        }
                        else
                        {
                            ContentWriter writer = contentService.getWriter(
                                    nodeService.getChildByName(parentFolderRef, ContentModel.ASSOC_CONTAINS, name),
                                    ContentModel.PROP_CONTENT,
                                    true);
                            writer.guessMimetype(name);
                            writer.putContent(content);
                            if (logger.isDebugEnabled())
                                logger.debug("createDocument (updated): " + name);
                        }
                    }
                    finally
                    {
                        behaviourFilter.enableBehaviour(parentFolderRef, ContentModel.ASPECT_AUDITABLE);
                    }
                    
                    return null;
                }
            }, runAsUser);
        }
    }
    
    /**
     * Get the RunAs user need to execute a Write operation on the given path.
     * 
     * @param path  Document path
     * @return runas user - will be the Full Authenticated User or System as required
     */
    protected String getPathRunAsUser(final String path)
    {
        // check we actually are the user we are creating a user specific path for
        String runAsUser = AuthenticationUtil.getFullyAuthenticatedUser();
        String userId = null;
        Matcher matcher;
        if ((matcher = USER_PATTERN_1.matcher(path)).matches())
        {
            userId = matcher.group(1);
        }
        else if ((matcher = USER_PATTERN_2.matcher(path)).matches())
        {
            userId = matcher.group(1);
        }
        if (userId != null && userId.equals(runAsUser))
        {
            runAsUser = AuthenticationUtil.getSystemUserName();
        }
        return runAsUser;
    }

    /**
     * Updates an existing document.
     * <p>
     * Update methods are user authenticated, so the modification of site config must be
     * allowed for the current user.
     * 
     * @param path          document path to update
     * @param content       content to update the document with
     */
    @Override
    protected void updateDocument(final WebScriptResponse res, String store, final String path, final InputStream content)
    {
        final String runAsUser = getPathRunAsUser(path);
        AuthenticationUtil.runAs(new RunAsWork<Void>()
        {
            @SuppressWarnings("synthetic-access")
            public Void doWork() throws Exception
            {
                final String encpath = encodePath(path);
                final FileInfo fileInfo = resolveFilePath(encpath);
                if (fileInfo == null || fileInfo.isFolder())
                {
                    res.setStatus(Status.STATUS_NOT_FOUND);
                    return null;
                }
                
                try
                {
                    ContentWriter writer = contentService.getWriter(fileInfo.getNodeRef(), ContentModel.PROP_CONTENT, true);
                    writer.putContent(content);
                    if (logger.isDebugEnabled())
                        logger.debug("updateDocument: " + fileInfo.toString());
                }
                catch (AccessDeniedException ae)
                {
                    res.setStatus(Status.STATUS_UNAUTHORIZED);
                    throw ae;
                }
                return null;
            }
        }, runAsUser);
    }
    
    /**
     * Deletes an existing document.
     * <p>
     * Delete methods are user authenticated, so the deletion of the document must be
     * allowed for the current user.
     * 
     * @param path  document path
     */
    @Override
    protected void deleteDocument(final WebScriptResponse res, final String store, final String path)
    {
        final String encpath = encodePath(path);
        final FileInfo fileInfo = resolveFilePath(encpath);
        if (fileInfo == null || fileInfo.isFolder())
        {
            res.setStatus(Status.STATUS_NOT_FOUND);
            return;
        }
        
        final String runAsUser = getPathRunAsUser(path);
        AuthenticationUtil.runAs(new RunAsWork<Void>()
        {
            @SuppressWarnings("synthetic-access")
            public Void doWork() throws Exception
            {
                try
                {
                    final NodeRef fileRef = fileInfo.getNodeRef();
                    // MNT-16371: Revoke ownership privileges for surf-config folder contents, to tighten access for former SiteManagers.
                    nodeService.addAspect(fileRef, ContentModel.ASPECT_TEMPORARY, null);

                    // ALF-17729
                    NodeRef parentFolderRef = unprotNodeService.getPrimaryParent(fileRef).getParentRef();
                    behaviourFilter.disableBehaviour(parentFolderRef, ContentModel.ASPECT_AUDITABLE);

                    try
                    {
                        nodeService.deleteNode(fileRef);
                    }
                    finally
                    {
                        behaviourFilter.enableBehaviour(parentFolderRef, ContentModel.ASPECT_AUDITABLE);
                    }

                    if (logger.isDebugEnabled())
                        logger.debug("deleteDocument: " + fileInfo.toString());
                }
                catch (AccessDeniedException ae)
                {
                    res.setStatus(Status.STATUS_UNAUTHORIZED);
                    throw ae;
                }
                return null;
            }
        }, runAsUser);
    }

    /**
     * Lists the document paths under a given path.
     * <p>
     * The output will be the list of relative document paths found under the path.
     * Separated by newline characters.
     * 
     * @param path      document path
     * @param recurse   true to peform a recursive list, false for direct children only.
     * 
     * @throws IOException if an error occurs listing the documents
     */
    @Override
    protected void listDocuments(final WebScriptResponse res, final String store, final String path, final boolean recurse)
        throws IOException
    {
        AuthenticationUtil.runAs(new RunAsWork<Void>()
        {
            @SuppressWarnings("synthetic-access")
            public Void doWork() throws Exception
            {
                res.setContentType("text/plain;charset=UTF-8");
                
                final String encpath = encodePath(path);
                final FileInfo fileInfo = resolveNodePath(encpath, false, true);
                if (fileInfo == null || !fileInfo.isFolder())
                {
                    res.setStatus(Status.STATUS_NOT_FOUND);
                    return null;
                }
                
                try
                {
                    outputFileNodes(res.getWriter(), fileInfo, aquireSurfConfigRef(encpath, false), "*", recurse);
                }
                catch (AccessDeniedException ae)
                {
                    res.setStatus(Status.STATUS_UNAUTHORIZED);
                }
                finally
                {
                    res.getWriter().close();
                }
                return null;
            }
        }, AuthenticationUtil.getSystemUserName());
    }
    
    /**
     * Lists the document paths matching a file pattern under a given path.
     * 
     * The output will be the list of relative document paths found under the path that
     * match the given file pattern. Separated by newline characters.
     * 
     * @param path      document path
     * @param pattern   file pattern to match - allows wildcards e.g. page.*.site.xml
     * 
     * @throws IOException if an error occurs listing the documents
     */
    @Override
    protected void listDocuments(final WebScriptResponse res, final String store, final String path, final String pattern)
        throws IOException
    {
        AuthenticationUtil.runAs(new RunAsWork<Void>()
        {
            @SuppressWarnings("synthetic-access")
            public Void doWork() throws Exception
            {
                res.setContentType("text/plain;charset=UTF-8");
                
                String filePattern;
                if (pattern == null || pattern.length() == 0)
                {
                    filePattern = "*";
                }
                else
                {
                    // need to ensure match pattern is path encoded - but don't encode * character!
                    StringBuilder buf = new StringBuilder(pattern.length());
                    for (StringTokenizer t = new StringTokenizer(pattern, "*"); t.hasMoreTokens(); /**/)
                    {
                        buf.append(encodePath(t.nextToken()));
                        if (t.hasMoreTokens())
                        {
                            buf.append('*');
                        }
                    }
                    // ensure the escape character is itself escaped
                    filePattern = buf.toString().replace("\\", "\\\\");
                }
                
                // ensure we pass in the file pattern as it is used as part of the folder match - i.e.
                // for a site component set e.g. /alfresco/site-data/components/page.*.site~xyz~dashboard.xml
                final String encpath = encodePath(path);
                final FileInfo fileInfo = resolveNodePath(encpath, filePattern, false, true);
                if (fileInfo == null || !fileInfo.isFolder())
                {
                    res.setStatus(Status.STATUS_NOT_FOUND);
                    return null;
                }
                
                if (logger.isDebugEnabled())
                    logger.debug("listDocuments() pattern: " + filePattern);
                
                try
                {
                    outputFileNodes(
                            res.getWriter(), fileInfo,
                            aquireSurfConfigRef(encpath + "/" + filePattern, false),
                            filePattern, false);
                }
                catch (AccessDeniedException ae)
                {
                    res.setStatus(Status.STATUS_UNAUTHORIZED);
                }
                finally
                {
                    res.getWriter().close();
                }
                return null;
            }
        }, AuthenticationUtil.getSystemUserName());
    }

    /**
     * @param path      cm:name based root relative path
     *                  example: /alfresco/site-data/pages/customise-user-dashboard.xml
     * 
     * @return FileInfo representing the file/folder at the specified path location
     *         or null if the supplied path does not exist in the store
     */
    private FileInfo resolveFilePath(final String path)
    {
        return resolveNodePath(path, false, false);
    }
    
   /**
     * @param path      cm:name based root relative path
     *                  example: /alfresco/site-data/pages/customise-user-dashboard.xml
     *                           /alfresco/site-data/components
     * @param create    if true create the config and folder dirs for the given path returning
     *                  the FileInfo for the last parent in the path, if false only attempt to
     *                  resolve the folder path if it exists returning the last element.
     * @param isFolder  True if the path is for a folder, false if it ends in a filename
     * 
     * @return FileInfo representing the file/folder at the specified path location (see create
     *         parameter above) or null if the supplied path does not exist in the store.
     */
    private FileInfo resolveNodePath(final String path, final boolean create, final boolean isFolder)
    {
        return resolveNodePath(path, null, create, isFolder);
    }
    
    /**
     * @param path      cm:name based root relative path
     *                  example: /alfresco/site-data/pages/customise-user-dashboard.xml
     *                           /alfresco/site-data/components
     * @param pattern   optional pattern that is used as part of the match to aquire the surf-config
     *                  folder under the appropriate sites or user location.
     * @param create    if true create the config and folder dirs for the given path returning
     *                  the FileInfo for the last parent in the path, if false only attempt to
     *                  resolve the folder path if it exists returning the last element.
     * @param isFolder  True if the path is for a folder, false if it ends in a filename
     * 
     * @return FileInfo representing the file/folder at the specified path location (see create
     *         parameter above) or null if the supplied path does not exist in the store.
     */
    private FileInfo resolveNodePath(final String path, final String pattern, final boolean create, final boolean isFolder)
    {
        if (logger.isDebugEnabled())
            logger.debug("Resolving path: " + path);

        final String adminUserName = AuthenticationUtil.getAdminUserName();

        FileInfo result = null;
        if (path != null)
        {
            // break down the path into its component elements
            List<String> pathElements = new ArrayList<String>(4);
            final StringTokenizer t = new StringTokenizer(path, "/");
            // the store requires paths of the form /alfresco/site-data/<objecttype>[/<folder>]/<file>.xml
            if (t.countTokens() >= 3)
            {
                t.nextToken();  // skip /alfresco
                t.nextToken();  // skip /site-data
                // collect remaining folder path (and file)
                while (t.hasMoreTokens())
                {
                    pathElements.add(t.nextToken());
                }
                
                NodeRef surfConfigRef = aquireSurfConfigRef(path + (pattern != null ? ("/" + pattern) : ""), create);
                try
                {
                    if (surfConfigRef != null)
                    {
                        if (create)
                        {
                            List<String> folders = isFolder ? pathElements : pathElements.subList(0, pathElements.size() - 1);
                            
                            List<FileFolderUtil.PathElementDetails> folderDetails = new ArrayList<>(pathElements.size());
                            Map<QName, Serializable> prop = new HashMap<>(2);
                            prop.put(ContentModel.PROP_IS_INDEXED, false);
                            prop.put(ContentModel.PROP_IS_CONTENT_INDEXED, false);
                            for (String element : folders)
                            {
                                Map<QName, Map<QName, Serializable>> aspects = Collections.singletonMap(ContentModel.ASPECT_INDEX_CONTROL, prop);
                                folderDetails.add(new FileFolderUtil.PathElementDetails(element, aspects));
                            }
                            // ensure folders exist down to the specified parent
                            // ALF-17729 / ALF-17796 - disable auditable on parent folders
                            Set<NodeRef> allCreatedFolders = new LinkedHashSet<>();
                            result = FileFolderUtil.makeFolders(
                                    this.fileFolderService,nodeService,
                                    surfConfigRef,
                                    folderDetails,
                                    ContentModel.TYPE_FOLDER,
                                    behaviourFilter,
                                    new HashSet<QName>(Arrays.asList(new QName[]{ContentModel.ASPECT_AUDITABLE})), allCreatedFolders);

                            // MNT-16371: Revoke ownership privileges for surf-config folder, to tighten access for former SiteManagers.
                            for(NodeRef nodeRef : allCreatedFolders)
                            {
                                ownableService.setOwner(nodeRef, adminUserName);
                            }
                        }
                        else
                        {
                            // perform the cm:name path lookup against our config root node
                            result = this.fileFolderService.resolveNamePath(surfConfigRef, pathElements);
                        }
                    }
                }
                catch (FileNotFoundException fnfErr)
                {
                    // this is a valid condition - we return null to indicate failed lookup
                }
            }
        }
        return result;
    }

    /**
     * Aquire (optionally create) the NodeRef to the "surf-config" folder as appropriate
     * for the given path.
     * <p>
     * Disassmbles the path to correct match either user, site or generic folder path.
     * 
     * @param path String
     * @param create boolean
     * 
     * @return NodeRef to the "surf-config" folder, or null if it does not exist yet.
     */
    private NodeRef aquireSurfConfigRef(final String path, final boolean create)
    {
        // remap the path into the appropriate Sites or site relative folder location
        // by first matching the path to appropriate user or site regex
        final boolean debug = logger.isDebugEnabled();
        String userId = null;
        String siteName = null;
        Matcher matcher;
        if (debug)
        {
            // user data is stored directly under the Sites folder along with
            // other generic config files - there is actually no need to match
            // anything other than site specific config other than for debug
            if ((matcher = USER_PATTERN_1.matcher(path)).matches())
            {
                userId = matcher.group(1);
            }
            else if ((matcher = USER_PATTERN_2.matcher(path)).matches())
            {
                userId = matcher.group(1);
            }
            else if ((matcher = SITE_PATTERN_1.matcher(path)).matches())
            {
                siteName = matcher.group(1);
            }
            else if ((matcher = SITE_PATTERN_2.matcher(path)).matches())
            {
                siteName = matcher.group(1);
            }
        }
        else if ((matcher = SITE_PATTERN_1.matcher(path)).matches())
        {
            siteName = matcher.group(1);
        }
        else if ((matcher = SITE_PATTERN_2.matcher(path)).matches())
        {
            siteName = matcher.group(1);
        }
        
        NodeRef surfConfigRef = null;
        if (siteName != null)
        {
            if (debug) logger.debug("...resolved site path id: " + siteName);
            NodeRef siteRef = getSiteNodeRef(siteName);
            if (siteRef != null)
            {
                surfConfigRef = getSurfConfigNodeRef(siteRef, create);
            }
        }
        else
        {
            if (debug)
            {
                if (userId != null)
                {
                    logger.debug("...resolved user path id: " + userId);
                }
                else
                {
                    logger.debug("...resolved to generic path.");
                }
            }
            surfConfigRef = getSurfConfigNodeRef(getRootNodeRef(), create);
        }
        return surfConfigRef;
    }
    
    /**
     * Return the "surf-config" noderef under the given root. No attempt will be made
     * to create the node if it does not exist yet.
     * 
     * @param rootRef   Root node reference where the "surf-config" folder should live
     * 
     * @return surf-config folder ref if found, null otherwise
     */
    private NodeRef getSurfConfigNodeRef(final NodeRef rootRef)
    {
        return getSurfConfigNodeRef(rootRef, false);
    }
    
    /**
     * Return the "surf-config" noderef under the given root. Optionally create the
     * folder if it does not exist yet. NOTE: must only be set to create if within a
     * WRITE transaction context.
     * <p>
     * Adds the "isIndexed = false" property to the surf-config folder node.
     * 
     * @param rootRef   Root node reference where the "surf-config" folder should live
     * @param create    True to create the folder if missing, false otherwise
     * 
     * @return surf-config folder ref if found, null otherwise if not creating
     */
    protected NodeRef getSurfConfigNodeRef(final NodeRef rootRef, final boolean create)
    {
        NodeRef surfConfigRef = this.unprotNodeService.getChildByName(
                rootRef, ContentModel.ASSOC_CONTAINS, SURF_CONFIG);
        if (create && surfConfigRef == null)
        {
            if (logger.isDebugEnabled())
                logger.debug("'surf-config' folder not found under path, creating...");
            QName assocQName = QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, SURF_CONFIG);
            Map<QName, Serializable> properties = new HashMap<QName, Serializable>(1, 1.0f);
            properties.put(ContentModel.PROP_NAME, (Serializable) SURF_CONFIG);
            ChildAssociationRef ref = this.unprotNodeService.createNode(
                    rootRef, ContentModel.ASSOC_CONTAINS, assocQName, ContentModel.TYPE_FOLDER, properties);
            surfConfigRef = ref.getChildRef();
            // surf-config needs to be hidden - applies index control aspect as part of the hidden aspect
            hiddenAspect.hideNode(ref.getChildRef(), false, false, false);

            // MNT-16371: Revoke inherited permission
            permissionService.setInheritParentPermissions(surfConfigRef, false);
            String siteName = siteService.getSiteShortName(rootRef);
            if (siteName != null)
            {
                // Revoke ownership privileges for surf-config folder, to tighten access for former SiteManagers.
                ownableService.setOwner(surfConfigRef, AuthenticationUtil.getAdminUserName());
                // Set site manager group permission
                String siteManagerGroup = siteService.getSiteRoleGroup(siteName, SiteModel.SITE_MANAGER);
                permissionService.setPermission(surfConfigRef, siteManagerGroup, SiteModel.SITE_MANAGER, true);
            }
        }
        return surfConfigRef;
    }

    /**
     * @return NodeRef to the shared components config folder
     */
    protected NodeRef getGlobalComponentsNodeRef()
    {
        NodeRef result = null;
        
        NodeRef surfRef = getSurfConfigNodeRef(siteService.getSiteRoot());
        if (surfRef != null)
        {
            result = nodeService.getChildByName(surfRef, ContentModel.ASSOC_CONTAINS, PATH_COMPONENTS);
        }
        
        return result;
    }

    /**
     * @return NodeRef to the shared user config folder
     */
    protected NodeRef getGlobalUserFolderNodeRef()
    {
        NodeRef result = null;
        
        NodeRef surfRef = getSurfConfigNodeRef(siteService.getSiteRoot());
        if (surfRef != null)
        {
            NodeRef pagesRef = nodeService.getChildByName(surfRef, ContentModel.ASSOC_CONTAINS, PATH_PAGES);
            if (pagesRef != null)
            {
                result = nodeService.getChildByName(pagesRef, ContentModel.ASSOC_CONTAINS, PATH_USER);
            }
        }
        
        return result;
    }

    /**
     * Generate the search pattern for a Surf config location for a user name.
     * 
     * @param userName  to build pattern for
     * @return the search pattern
     */
    protected String buildUserConfigSearchPattern(String userName)
    {
        return USER_CONFIG_PATTERN.replace("(*)", encodePath(userName));
    }

    /**
     * @return the Sites folder root node reference
     */
    private NodeRef getRootNodeRef()
    {
        return this.siteService.getSiteRoot();
    }
    
    /**
     * @param shortName     Site shortname
     * 
     * @return the given Site folder node reference
     */
    private NodeRef getSiteNodeRef(String shortName)
    {
        SiteInfo siteInfo = this.siteService.getSite(shortName); 
        return siteInfo != null ? siteInfo.getNodeRef() : null;
    }
    
    /**
     * Output the matching file paths a node contains based on a pattern search.
     * 
     * @param out Writer for output - relative paths separated by newline characters
     * @param surfConfigRef Surf-Config folder
     * @param fileInfo The FileInfo node to use as the parent
     * @param pattern Optional pattern to match filenames against ("*" is match all)
     * @param recurse True to recurse sub-directories
     * @throws IOException
     */
    private void outputFileNodes(Writer out, FileInfo fileInfo, NodeRef surfConfigRef, String pattern, boolean recurse) throws IOException
    {
        if (surfConfigRef != null)
        {
            final boolean debug = logger.isDebugEnabled();
            PagingResults<FileInfo> files = getFileNodes(fileInfo, pattern, recurse);
            
            final Map<NodeRef, String> nameCache = new HashMap<NodeRef, String>();
            for (final FileInfo file : files.getPage())
            {
                // walking up the parent tree manually until the "surf-config" parent is hit
                // and manually appending the rest of the cm:name path down to the node.
                final StringBuilder displayPath = new StringBuilder(64);
                NodeRef ref = unprotNodeService.getPrimaryParent(file.getNodeRef()).getParentRef();
                while (!ref.equals(surfConfigRef))
                {
                    String name = nameCache.get(ref);
                    if (name == null)
                    {
                        name = (String) unprotNodeService.getProperty(ref, ContentModel.PROP_NAME);
                        nameCache.put(ref, name);
                    }
                    displayPath.insert(0, '/');
                    displayPath.insert(0, name);
                    ref = unprotNodeService.getPrimaryParent(ref).getParentRef();
                }
                
                out.write("/alfresco/site-data/");
                out.write(URLDecoder.decode(displayPath.toString()));
                out.write(URLDecoder.decode(file.getName()));
                out.write('\n');
                if (debug) logger.debug("   /alfresco/site-data/" + displayPath.toString() + file.getName());
            }
        }
    }
    
    protected PagingResults<FileInfo> getFileNodes(FileInfo fileInfo, String pattern, boolean recurse)
    {
        return fileFolderService.list(
                fileInfo.getNodeRef(), true, false,
                pattern, null, null,
                new PagingRequest(CannedQueryPageDetails.DEFAULT_PAGE_SIZE));
    }
}