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}