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