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.log4j.Logger;
023import org.apache.wiki.api.core.Engine;
024import org.apache.wiki.api.core.Page;
025import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
026import org.apache.wiki.api.exceptions.ProviderException;
027import org.apache.wiki.api.providers.PageProvider;
028import org.apache.wiki.api.providers.WikiProvider;
029import org.apache.wiki.api.search.QueryItem;
030import org.apache.wiki.api.search.SearchResult;
031import org.apache.wiki.api.spi.Wiki;
032import org.apache.wiki.search.SearchMatcher;
033import org.apache.wiki.search.SearchResultComparator;
034import org.apache.wiki.util.FileUtil;
035import org.apache.wiki.util.TextUtil;
036
037import java.io.File;
038import java.io.FileInputStream;
039import java.io.FileNotFoundException;
040import java.io.FileOutputStream;
041import java.io.FilenameFilter;
042import java.io.IOException;
043import java.io.InputStream;
044import java.io.OutputStreamWriter;
045import java.io.PrintWriter;
046import java.nio.charset.StandardCharsets;
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 = Logger.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 = false;
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            for( final String windowsDeviceName : WINDOWS_DEVICE_NAMES ) {
180                if( windowsDeviceName.equals( pn ) ) {
181                    pagename = "$$$" + pagename;
182                }
183            }
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 = new FileInputStream( pagedata ) ) {
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 result;
262    }
263
264    /**
265     *  {@inheritDoc}
266     */
267    @Override
268    public void putPageText( final Page page, final String text ) throws ProviderException {
269        final File file = findPage( page.getName() );
270        try( final PrintWriter out = new PrintWriter( new OutputStreamWriter( new FileOutputStream( file ), m_encoding ) ) ) {
271            out.print( text );
272        } catch( final IOException e ) {
273            log.error( "Saving failed", e );
274        }
275    }
276
277    /**
278     *  {@inheritDoc}
279     */
280    @Override
281    public Collection< Page > getAllPages()  throws ProviderException {
282        log.debug("Getting all pages...");
283        final ArrayList< Page > set = new ArrayList<>();
284        final File wikipagedir = new File( m_pageDirectory );
285        final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() );
286
287        if( wikipages == null ) {
288            log.error("Wikipages directory '" + m_pageDirectory + "' does not exist! Please check " + PROP_PAGEDIR + " in jspwiki.properties.");
289            throw new ProviderException( "Page directory does not exist" );
290        }
291
292        for( final File wikipage : wikipages ) {
293            final String wikiname = wikipage.getName();
294            final int cutpoint = wikiname.lastIndexOf( FILE_EXT );
295            final Page page = getPageInfo( unmangleName( wikiname.substring( 0, cutpoint ) ), PageProvider.LATEST_VERSION );
296            if( page == null ) {
297                // This should not really happen.
298                // FIXME: Should we throw an exception here?
299                log.error( "Page " + wikiname + " was found in directory listing, but could not be located individually." );
300                continue;
301            }
302
303            set.add( page );
304        }
305
306        return set;
307    }
308
309    /**
310     *  Does not work.
311     *
312     *  @param date {@inheritDoc}
313     *  @return {@inheritDoc}
314     */
315    @Override
316    public Collection< Page > getAllChangedSince( final Date date )
317    {
318        return new ArrayList<>(); // FIXME
319    }
320
321    /**
322     *  {@inheritDoc}
323     */
324    @Override
325    public int getPageCount() {
326        final File wikipagedir = new File( m_pageDirectory );
327        final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() );
328        return wikipages != null ? wikipages.length : 0;
329    }
330
331    /**
332     * Iterates through all WikiPages, matches them against the given query, and returns a Collection of SearchResult objects.
333     *
334     * {@inheritDoc}
335     */
336    @Override
337    public Collection< SearchResult > findPages( final QueryItem[] query ) {
338        final File wikipagedir = new File( m_pageDirectory );
339        final TreeSet< SearchResult > res = new TreeSet<>( new SearchResultComparator() );
340        final SearchMatcher matcher = new SearchMatcher( m_engine, query );
341        final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() );
342
343        if( wikipages != null ) {
344            for( final File wikipage : wikipages ) {
345                final String filename = wikipage.getName();
346                final int cutpoint = filename.lastIndexOf( FILE_EXT );
347                final String wikiname = unmangleName( filename.substring( 0, cutpoint ) );
348                try( final FileInputStream input = new FileInputStream( wikipage ) ) {
349                    final String pagetext = FileUtil.readContents( input, m_encoding );
350                    final SearchResult comparison = matcher.matchPageContent( wikiname, pagetext );
351                    if( comparison != null ) {
352                        res.add( comparison );
353                    }
354                } catch( final IOException e ) {
355                    log.error( "Failed to read " + filename, e );
356                }
357            }
358        }
359
360        return res;
361    }
362
363    /**
364     *  Always returns the latest version, since FileSystemProvider
365     *  does not support versioning.
366     *
367     *  {@inheritDoc}
368     */
369    @Override
370    public Page getPageInfo( final String page, final int version ) throws ProviderException {
371        final File file = findPage( page );
372        if( !file.exists() ) {
373            return null;
374        }
375
376        final Page p = Wiki.contents().page( m_engine, page );
377        p.setLastModified( new Date( file.lastModified() ) );
378
379        return p;
380    }
381
382    /**
383     *  The FileSystemProvider provides only one version.
384     *
385     *  {@inheritDoc}
386     */
387    @Override
388    public List< Page > getVersionHistory( final String page ) throws ProviderException {
389        final ArrayList< Page > list = new ArrayList<>();
390        list.add( getPageInfo( page, PageProvider.LATEST_VERSION ) );
391
392        return list;
393    }
394
395    /**
396     *  {@inheritDoc}
397     */
398    @Override
399    public String getProviderInfo()
400    {
401        return "";
402    }
403
404    /**
405     *  {@inheritDoc}
406     */
407    @Override
408    public void deleteVersion( final String pageName, final int version ) throws ProviderException {
409        if( version == WikiProvider.LATEST_VERSION ) {
410            final File f = findPage( pageName );
411            f.delete();
412        }
413    }
414
415    /**
416     *  {@inheritDoc}
417     */
418    @Override
419    public void deletePage( final String pageName ) throws ProviderException {
420        final File f = findPage( pageName );
421        f.delete();
422    }
423
424    /**
425     * Set the custom properties provided into the given page.
426     *
427     * @since 2.10.2
428     */
429    protected void setCustomProperties( final Page page, final Properties properties ) {
430        final Enumeration< ? > propertyNames = properties.propertyNames();
431        while( propertyNames.hasMoreElements() ) {
432            final String key = ( String )propertyNames.nextElement();
433            if( !key.equals( Page.AUTHOR ) && !key.equals( Page.CHANGENOTE ) && !key.equals( Page.VIEWCOUNT ) ) {
434                page.setAttribute( key, properties.get( key ) );
435            }
436        }
437    }
438
439    /**
440     * Get custom properties using {@link #addCustomProperties(Page, Properties)}, validate them using {@link #validateCustomPageProperties(Properties)}
441     * and add them to default properties provided
442     *
443     * @since 2.10.2
444     */
445    protected void getCustomProperties( final Page page, final Properties defaultProperties ) throws IOException {
446        final Properties customPageProperties = addCustomProperties( page, defaultProperties );
447        validateCustomPageProperties( customPageProperties );
448        defaultProperties.putAll( customPageProperties );
449    }
450
451    /**
452     * By default all page attributes that start with "@" are returned as custom properties.
453     * This can be overwritten by custom FileSystemProviders to save additional properties.
454     * CustomPageProperties are validated by {@link #validateCustomPageProperties(Properties)}
455     *
456     * @since 2.10.2
457     * @param page the current page
458     * @param props the default properties of this page
459     * @return default implementation returns empty Properties.
460     */
461    protected Properties addCustomProperties( final Page page, final Properties props ) {
462        final Properties customProperties = new Properties();
463        if( page != null ) {
464            final Map< String, Object > atts = page.getAttributes();
465            for( final String key : atts.keySet() ) {
466                final Object value = atts.get( key );
467                if( key.startsWith( "@" ) && value != null ) {
468                    customProperties.put( key, value.toString() );
469                }
470            }
471
472        }
473        return customProperties;
474    }
475
476    /**
477     * Default validation, validates that key and value is ASCII <code>StringUtils.isAsciiPrintable()</code> and within lengths set up in jspwiki-custom.properties.
478     * This can be overwritten by custom FileSystemProviders to validate additional properties
479     * See https://issues.apache.org/jira/browse/JSPWIKI-856
480     * @since 2.10.2
481     * @param customProperties the custom page properties being added
482     */
483    protected void validateCustomPageProperties( final Properties customProperties ) throws IOException {
484        // Default validation rules
485        if( customProperties != null && !customProperties.isEmpty() ) {
486            if( customProperties.size() > MAX_PROPLIMIT ) {
487                throw new IOException( "Too many custom properties. You are adding " + customProperties.size() + ", but max limit is " + MAX_PROPLIMIT );
488            }
489            final Enumeration< ? > propertyNames = customProperties.propertyNames();
490            while( propertyNames.hasMoreElements() ) {
491                final String key = ( String )propertyNames.nextElement();
492                final String value = ( String )customProperties.get( key );
493                if( key != null ) {
494                    if( key.length() > MAX_PROPKEYLENGTH ) {
495                        throw new IOException( "Custom property key " + key + " is too long. Max allowed length is " + MAX_PROPKEYLENGTH );
496                    }
497                    if( !StringUtils.isAsciiPrintable( key ) ) {
498                        throw new IOException( "Custom property key " + key + " is not simple ASCII!" );
499                    }
500                }
501                if( value != null ) {
502                    if( value.length() > MAX_PROPVALUELENGTH ) {
503                        throw new IOException( "Custom property key " + key + " has value that is too long. Value=" + value + ". Max allowed length is " + MAX_PROPVALUELENGTH );
504                    }
505                    if( !StringUtils.isAsciiPrintable( value ) ) {
506                        throw new IOException( "Custom property key " + key + " has value that is not simple ASCII! Value=" + value );
507                    }
508                }
509            }
510        }
511    }
512
513    /**
514     *  A simple filter which filters only those filenames which correspond to the
515     *  file extension used.
516     */
517    public static class WikiFileFilter implements FilenameFilter {
518        /**
519         *  {@inheritDoc}
520         */
521        @Override
522        public boolean accept( final File dir, final String name ) {
523            return name.endsWith( FILE_EXT );
524        }
525    }
526
527}