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