    001/*
    002    Licensed to the Apache Software Foundation (ASF) under one
    003    or more contributor license agreements.  See the NOTICE file
    004    distributed with this work for additional information
    005    regarding copyright ownership.  The ASF licenses this file
    006    to you under the Apache License, Version 2.0 (the
    007    "License"); you may not use this file except in compliance
    008    with the License.  You may obtain a copy of the License at
    009
    010       http://www.apache.org/licenses/LICENSE-2.0
    011
    012    Unless required by applicable law or agreed to in writing,
    013    software distributed under the License is distributed on an
    014    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    015    KIND, either express or implied.  See the License for the
    016    specific language governing permissions and limitations
    017    under the License.
    018 */
    019package org.apache.wiki.providers;
    020
    021import org.apache.commons.lang3.StringUtils;
    022import org.apache.logging.log4j.LogManager;
    023import org.apache.logging.log4j.Logger;
    024import org.apache.wiki.api.core.Engine;
    025import org.apache.wiki.api.core.Page;
    026import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
    027import org.apache.wiki.api.exceptions.ProviderException;
    028import org.apache.wiki.api.providers.PageProvider;
    029import org.apache.wiki.api.providers.WikiProvider;
    030import org.apache.wiki.api.search.QueryItem;
    031import org.apache.wiki.api.search.SearchResult;
    032import org.apache.wiki.api.spi.Wiki;
    033import org.apache.wiki.search.SearchMatcher;
    034import org.apache.wiki.search.SearchResultComparator;
    035import org.apache.wiki.util.FileUtil;
    036import org.apache.wiki.util.TextUtil;
    037
    038import java.io.File;
    039import java.io.FileNotFoundException;
    040import java.io.FilenameFilter;
    041import java.io.IOException;
    042import java.io.InputStream;
    043import java.io.OutputStreamWriter;
    044import java.io.PrintWriter;
    045import java.nio.charset.StandardCharsets;
    046import java.nio.file.Files;
    047import java.util.ArrayList;
    048import java.util.Collection;
    049import java.util.Date;
    050import java.util.Enumeration;
    051import java.util.List;
    052import java.util.Map;
    053import java.util.Properties;
    054import java.util.TreeSet;
    055import org.apache.commons.lang3.SystemUtils;
    056
    057
    058/**
    059 *  Provides a simple directory based repository for Wiki pages.
    060 *  <P>
    061 *  All files have ".txt" appended to make life easier for those who insist on using Windows or other software which makes assumptions
    062 *  on the files contents based on its name.
    063 *  <p>
    064 *  This class functions as a superclass to all file based providers.
    065 *
    066 *  @since 2.1.21.
    067 */
    068public abstract class AbstractFileProvider implements PageProvider {
    069
    070    private static final Logger LOG = LogManager.getLogger(AbstractFileProvider.class);
    071    private String m_pageDirectory = "/tmp/";
    072    protected String m_encoding;
    073
    074    protected Engine m_engine;
    075
    076    public static final String PROP_CUSTOMPROP_MAXLIMIT = "custom.pageproperty.max.allowed";
    077    public static final String PROP_CUSTOMPROP_MAXKEYLENGTH = "custom.pageproperty.key.length";
    078    public static final String PROP_CUSTOMPROP_MAXVALUELENGTH = "custom.pageproperty.value.length";
    079
    080    public static final int DEFAULT_MAX_PROPLIMIT = 200;
    081    public static final int DEFAULT_MAX_PROPKEYLENGTH = 255;
    082    public static final int DEFAULT_MAX_PROPVALUELENGTH = 4096;
    083
    084    /** This parameter limits the number of custom page properties allowed on a page */
    085    public static int MAX_PROPLIMIT = DEFAULT_MAX_PROPLIMIT;
    086
    087    /**
    088     * This number limits the length of a custom page property key length. The default value here designed with future JDBC providers in mind.
    089     */
    090    public static int MAX_PROPKEYLENGTH = DEFAULT_MAX_PROPKEYLENGTH;
    091
    092    /**
    093     * This number limits the length of a custom page property value length. The default value here designed with future JDBC providers in mind.
    094     */
    095    public static int MAX_PROPVALUELENGTH = DEFAULT_MAX_PROPVALUELENGTH;
    096
    097    /** Name of the property that defines where page directories are. */
    098    public static final String PROP_PAGEDIR = "jspwiki.fileSystemProvider.pageDir";
    099
    100    /**
    101     *  All files should have this extension to be recognized as JSPWiki files. We default to .txt, because that is probably easiest for
    102     *  Windows users, and guarantees correct handling.
    103     */
    104    public static final String FILE_EXT = ".txt";
    105
    106    /** The default encoding. */
    107    public static final String DEFAULT_ENCODING = StandardCharsets.ISO_8859_1.toString();
    108
    109    private boolean m_windowsHackNeeded;
    110
    111    /**
    112     *  {@inheritDoc}
    113     *  @throws FileNotFoundException If the specified page directory does not exist.
    114     *  @throws IOException In case the specified page directory is a file, not a directory.
    115     */
    116    @Override
    117    public void initialize( final Engine engine, final Properties properties ) throws NoRequiredPropertyException, IOException, FileNotFoundException {
    118        LOG.debug( "Initing FileSystemProvider" );
    119        m_pageDirectory = TextUtil.getCanonicalFilePathProperty( properties, PROP_PAGEDIR,
    120                                                          System.getProperty( "user.home" ) + File.separator + "jspwiki-files" );
    121
    122        final File f = new File( m_pageDirectory );
    123
    124        if( !f.exists() ) {
    125            if( !f.mkdirs() ) {
    126                throw new IOException( "Failed to create page directory " + f.getAbsolutePath() + " , please check property " + PROP_PAGEDIR );
    127            }
    128        } else {
    129            if( !f.isDirectory() ) {
    130                throw new IOException( "Page directory is not a directory: " + f.getAbsolutePath() );
    131            }
    132            if( !f.canWrite() ) {
    133                throw new IOException( "Page directory is not writable: " + f.getAbsolutePath() );
    134            }
    135        }
    136
    137        m_engine = engine;
    138        m_encoding = properties.getProperty( Engine.PROP_ENCODING, DEFAULT_ENCODING );
    139        m_windowsHackNeeded = SystemUtils.IS_OS_WINDOWS;
    140
    141        MAX_PROPLIMIT = TextUtil.getIntegerProperty( properties, PROP_CUSTOMPROP_MAXLIMIT, DEFAULT_MAX_PROPLIMIT );
    142        MAX_PROPKEYLENGTH = TextUtil.getIntegerProperty( properties, PROP_CUSTOMPROP_MAXKEYLENGTH, DEFAULT_MAX_PROPKEYLENGTH );
    143        MAX_PROPVALUELENGTH = TextUtil.getIntegerProperty( properties, PROP_CUSTOMPROP_MAXVALUELENGTH, DEFAULT_MAX_PROPVALUELENGTH );
    144
    145        LOG.info( "Wikipages are read from '" + m_pageDirectory + "'" );
    146    }
    147
    148
    149    String getPageDirectory()
    150    {
    151        return m_pageDirectory;
    152    }
    153
    154    private static final String[] WINDOWS_DEVICE_NAMES = {
    155        "con", "prn", "nul", "aux", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
    156        "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9"
    157    };
    158
    159    /**
    160     *  This makes sure that the queried page name is still readable by the file system.  For example, all XML entities
    161     *  and slashes are encoded with the percent notation.
    162     *
    163     *  @param pagename The name to mangle
    164     *  @return The mangled name.
    165     */
    166    protected String mangleName( String pagename ) {
    167        pagename = TextUtil.urlEncode( pagename, m_encoding );
    168        pagename = TextUtil.replaceString( pagename, "/", "%2F" );
    169
    170        //  Names which start with a dot must be escaped to prevent problems. Since we use URL encoding, this is invisible in our unescaping.
    171        if( pagename.startsWith( "." ) ) {
    172            pagename = "%2E" + pagename.substring( 1 );
    173        }
    174
    175        if( m_windowsHackNeeded ) {
    176            final String pn = pagename.toLowerCase();
    177            final StringBuilder pagenameBuilder = new StringBuilder(pagename);
    178            for( final String windowsDeviceName : WINDOWS_DEVICE_NAMES ) {
    179                if( windowsDeviceName.equals( pn ) ) {
    180                    pagenameBuilder.insert(0, "$$$");
    181                }
    182            }
    183            pagename = pagenameBuilder.toString();
    184        }
    185
    186        return pagename;
    187    }
    188
    189    /**
    190     *  This makes the reverse of mangleName.
    191     *
    192     *  @param filename The filename to unmangle
    193     *  @return The unmangled name.
    194     */
    195    protected String unmangleName( String filename ) {
    196        // The exception should never happen.
    197        if( m_windowsHackNeeded && filename.startsWith( "$$$" ) && filename.length() > 3 ) {
    198            filename = filename.substring( 3 );
    199        }
    200
    201        return TextUtil.urlDecode( filename, m_encoding );
    202    }
    203
    204    /**
    205     *  Finds a Wiki page from the page repository.
    206     *
    207     *  @param page The name of the page.
    208     *  @return A File to the page.  May be null.
    209     */
    210    protected File findPage( final String page ) {
    211        return new File( m_pageDirectory, mangleName( page ) + FILE_EXT );
    212    }
    213
    214    /**
    215     *  {@inheritDoc}
    216     */
    217    @Override
    218    public boolean pageExists( final String page ) {
    219        return findPage( page ).exists();
    220    }
    221
    222    /**
    223     *  {@inheritDoc}
    224     */
    225    @Override
    226    public boolean pageExists( final String page, final int version ) {
    227        return pageExists( page );
    228    }
    229
    230    /**
    231     *  This implementation just returns the current version, as filesystem does not provide versioning information for now.
    232     *
    233     *  {@inheritDoc}
    234     */
    235    @Override
    236    public String getPageText( final String page, final int version ) throws ProviderException {
    237        return getPageText( page );
    238    }
    239
    240    /**
    241     *  Read the text directly from the correct file.
    242     */
    243    private String getPageText( final String page ) {
    244        String result  = null;
    245        final File pagedata = findPage( page );
    246        if( pagedata.exists() ) {
    247            if( pagedata.canRead() ) {
    248                try( final InputStream in = Files.newInputStream( pagedata.toPath() ) ) {
    249                    result = FileUtil.readContents( in, m_encoding );
    250                } catch( final IOException e ) {
    251                    LOG.error( "Failed to read", e );
    252                }
    253            } else {
    254                LOG.warn( "Failed to read page '" + page + "' from '" + pagedata.getAbsolutePath() + "', possibly a permissions problem" );
    255            }
    256        } else {
    257            // This is okay.
    258            LOG.info( "New page '" + page + "'" );
    259        }
    260
    261        return deobfuscate(result);
    262    }
    263
    264    /**
    265     * An api hook to provide obfuscation of data before it's written to disk.
    266     * Useful for wiring in your own custom encryption mechanism if so desired.
    267     * @since 3.0.0
    268     *
    269     * @param pageContent
    270     * @return
    271     */
    272    public String obfuscate(String pageContent) {
    273        return pageContent;
    274    }
    275
    276    /**
    277     * An api hook to provide deobfuscation of data before it's written to disk.
    278     * Useful for wiring in your own custom decryption mechanism if so desired.
    279     * @since 3.0.0
    280     * @param pageContent
    281     * @return
    282     */
    283    public String deobfuscate(String pageContent) {
    284        return pageContent;
    285    }
    286
    287    /**
    288     *  {@inheritDoc}
    289     */
    290    @Override
    291    public void putPageText( final Page page, final String text ) throws ProviderException {
    292        final File file = findPage( page.getName() );
    293        try( final PrintWriter out = new PrintWriter( new OutputStreamWriter( Files.newOutputStream( file.toPath() ), m_encoding ) ) ) {
    294            out.print( obfuscate(text) );
    295        } catch( final IOException e ) {
    296            LOG.error( "Saving failed", e );
    297        }
    298    }
    299
    300    /**
    301     *  {@inheritDoc}
    302     */
    303    @Override
    304    public Collection< Page > getAllPages()  throws ProviderException {
    305        LOG.debug("Getting all pages...");
    306        final ArrayList< Page > set = new ArrayList<>();
    307        final File wikipagedir = new File( m_pageDirectory );
    308        final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() );
    309
    310        if( wikipages == null ) {
    311            LOG.error("Wikipages directory '" + m_pageDirectory + "' does not exist! Please check " + PROP_PAGEDIR + " in jspwiki.properties.");
    312            throw new ProviderException( "Page directory does not exist" );
    313        }
    314
    315        for( final File wikipage : wikipages ) {
    316            final String wikiname = wikipage.getName();
    317            final int cutpoint = wikiname.lastIndexOf( FILE_EXT );
    318            final Page page = getPageInfo( unmangleName( wikiname.substring( 0, cutpoint ) ), PageProvider.LATEST_VERSION );
    319            if( page == null ) {
    320                // This should not really happen.
    321                // FIXME: Should we throw an exception here?
    322                LOG.error( "Page " + wikiname + " was found in directory listing, but could not be located individually." );
    323                continue;
    324            }
    325
    326            set.add( page );
    327        }
    328
    329        return set;
    330    }
    331
    332    /**
    333     *  Does not work.
    334     *
    335     *  @param date {@inheritDoc}
    336     *  @return {@inheritDoc}
    337     */
    338    @Override
    339    public Collection< Page > getAllChangedSince( final Date date )
    340    {
    341        return new ArrayList<>(); // FIXME
    342    }
    343
    344    /**
    345     *  {@inheritDoc}
    346     */
    347    @Override
    348    public int getPageCount() {
    349        final File wikipagedir = new File( m_pageDirectory );
    350        final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() );
    351        return wikipages != null ? wikipages.length : 0;
    352    }
    353
    354    /**
    355     * Iterates through all WikiPages, matches them against the given query, and returns a Collection of SearchResult objects.
    356     *
    357     * {@inheritDoc}
    358     */
    359    @Override
    360    public Collection< SearchResult > findPages( final QueryItem[] query ) {
    361        final File wikipagedir = new File( m_pageDirectory );
    362        final TreeSet< SearchResult > res = new TreeSet<>( new SearchResultComparator() );
    363        final SearchMatcher matcher = new SearchMatcher( m_engine, query );
    364        final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() );
    365
    366        if( wikipages != null ) {
    367            for( final File wikipage : wikipages ) {
    368                final String filename = wikipage.getName();
    369                final int cutpoint = filename.lastIndexOf( FILE_EXT );
    370                final String wikiname = unmangleName( filename.substring( 0, cutpoint ) );
    371                try( final InputStream input = Files.newInputStream( wikipage.toPath() ) ) {
    372                    final String pagetext = FileUtil.readContents( input, m_encoding );
    373                    final SearchResult comparison = matcher.matchPageContent( wikiname, pagetext );
    374                    if( comparison != null ) {
    375                        res.add( comparison );
    376                    }
    377                } catch( final IOException e ) {
    378                    LOG.error( "Failed to read " + filename, e );
    379                }
    380            }
    381        }
    382
    383        return res;
    384    }
    385
    386    /**
    387     *  Always returns the latest version, since FileSystemProvider
    388     *  does not support versioning.
    389     *
    390     *  {@inheritDoc}
    391     */
    392    @Override
    393    public Page getPageInfo( final String page, final int version ) throws ProviderException {
    394        final File file = findPage( page );
    395        if( !file.exists() ) {
    396            return null;
    397        }
    398
    399        final Page p = Wiki.contents().page( m_engine, page );
    400        p.setLastModified( new Date( file.lastModified() ) );
    401
    402        return p;
    403    }
    404
    405    /**
    406     *  The FileSystemProvider provides only one version.
    407     *
    408     *  {@inheritDoc}
    409     */
    410    @Override
    411    public List< Page > getVersionHistory( final String page ) throws ProviderException {
    412        final ArrayList< Page > list = new ArrayList<>();
    413        list.add( getPageInfo( page, PageProvider.LATEST_VERSION ) );
    414
    415        return list;
    416    }
    417
    418    /**
    419     *  {@inheritDoc}
    420     */
    421    @Override
    422    public String getProviderInfo()
    423    {
    424        return "";
    425    }
    426
    427    /**
    428     *  {@inheritDoc}
    429     */
    430    @Override
    431    public void deleteVersion( final String pageName, final int version ) throws ProviderException {
    432        if( version == WikiProvider.LATEST_VERSION ) {
    433            final File f = findPage( pageName );
    434            f.delete();
    435        }
    436    }
    437
    438    /**
    439     *  {@inheritDoc}
    440     */
    441    @Override
    442    public void deletePage( final String pageName ) throws ProviderException {
    443        final File f = findPage( pageName );
    444        f.delete();
    445    }
    446
    447    /**
    448     * Set the custom properties provided into the given page.
    449     *
    450     * @since 2.10.2
    451     */
    452    protected void setCustomProperties( final Page page, final Properties properties ) {
    453        final Enumeration< ? > propertyNames = properties.propertyNames();
    454        while( propertyNames.hasMoreElements() ) {
    455            final String key = ( String )propertyNames.nextElement();
    456            if( !key.equals( Page.AUTHOR ) && !key.equals( Page.CHANGENOTE ) && !key.equals( Page.VIEWCOUNT ) ) {
    457                page.setAttribute( key, properties.get( key ) );
    458            }
    459        }
    460    }
    461
    462    /**
    463     * Get custom properties using {@link #addCustomProperties(Page, Properties)}, validate them using {@link #validateCustomPageProperties(Properties)}
    464     * and add them to default properties provided
    465     *
    466     * @since 2.10.2
    467     */
    468    protected void getCustomProperties( final Page page, final Properties defaultProperties ) throws IOException {
    469        final Properties customPageProperties = addCustomProperties( page, defaultProperties );
    470        validateCustomPageProperties( customPageProperties );
    471        defaultProperties.putAll( customPageProperties );
    472    }
    473
    474    /**
    475     * By default all page attributes that start with "@" are returned as custom properties.
    476     * This can be overwritten by custom FileSystemProviders to save additional properties.
    477     * CustomPageProperties are validated by {@link #validateCustomPageProperties(Properties)}
    478     *
    479     * @since 2.10.2
    480     * @param page the current page
    481     * @param props the default properties of this page
    482     * @return default implementation returns empty Properties.
    483     */
    484    protected Properties addCustomProperties( final Page page, final Properties props ) {
    485        final Properties customProperties = new Properties();
    486        if( page != null ) {
    487            final Map< String, Object > atts = page.getAttributes();
    488            for( final String key : atts.keySet() ) {
    489                final Object value = atts.get( key );
    490                if( key.startsWith( "@" ) && value != null ) {
    491                    customProperties.put( key, value.toString() );
    492                }
    493            }
    494
    495        }
    496        return customProperties;
    497    }
    498
    499    /**
    500     * Default validation, validates that key and value is ASCII <code>StringUtils.isAsciiPrintable()</code> and within lengths set up in jspwiki-custom.properties.
    501     * This can be overwritten by custom FileSystemProviders to validate additional properties
    502     * See https://issues.apache.org/jira/browse/JSPWIKI-856
    503     * @since 2.10.2
    504     * @param customProperties the custom page properties being added
    505     */
    506    protected void validateCustomPageProperties( final Properties customProperties ) throws IOException {
    507        // Default validation rules
    508        if( customProperties != null && !customProperties.isEmpty() ) {
    509            if( customProperties.size() > MAX_PROPLIMIT ) {
    510                throw new IOException( "Too many custom properties. You are adding " + customProperties.size() + ", but max limit is " + MAX_PROPLIMIT );
    511            }
    512            final Enumeration< ? > propertyNames = customProperties.propertyNames();
    513            while( propertyNames.hasMoreElements() ) {
    514                final String key = ( String )propertyNames.nextElement();
    515                final String value = ( String )customProperties.get( key );
    516                if( key != null ) {
    517                    if( key.length() > MAX_PROPKEYLENGTH ) {
    518                        throw new IOException( "Custom property key " + key + " is too long. Max allowed length is " + MAX_PROPKEYLENGTH );
    519                    }
    520                    if( !StringUtils.isAsciiPrintable( key ) ) {
    521                        throw new IOException( "Custom property key " + key + " is not simple ASCII!" );
    522                    }
    523                }
    524                if( value != null ) {
    525                    if( value.length() > MAX_PROPVALUELENGTH ) {
    526                        throw new IOException( "Custom property key " + key + " has value that is too long. Value=" + value + ". Max allowed length is " + MAX_PROPVALUELENGTH );
    527                    }
    528                    if( !StringUtils.isAsciiPrintable( value ) ) {
    529                        throw new IOException( "Custom property key " + key + " has value that is not simple ASCII! Value=" + value );
    530                    }
    531                }
    532            }
    533        }
    534    }
    535
    536    /**
    537     *  A simple filter which filters only those filenames which correspond to the
    538     *  file extension used.
    539     */
    540    public static class WikiFileFilter implements FilenameFilter {
    541        /**
    542         *  {@inheritDoc}
    543         */
    544        @Override
    545        public boolean accept( final File dir, final String name ) {
    546            return name.endsWith( FILE_EXT );
    547        }
    548    }
    549
    550}