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.log4j.Logger;
022import org.apache.wiki.InternalWikiException;
023import org.apache.wiki.WikiEngine;
024import org.apache.wiki.WikiPage;
025import org.apache.wiki.WikiProvider;
026import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
027import org.apache.wiki.api.exceptions.ProviderException;
028import org.apache.wiki.util.FileUtil;
029
030import java.io.BufferedInputStream;
031import java.io.BufferedOutputStream;
032import java.io.File;
033import java.io.FileInputStream;
034import java.io.FileOutputStream;
035import java.io.IOException;
036import java.io.InputStream;
037import java.io.OutputStream;
038import java.util.ArrayList;
039import java.util.Collection;
040import java.util.Date;
041import java.util.Iterator;
042import java.util.List;
043import java.util.Properties;
044
045/**
046 *  Provides a simple directory based repository for Wiki pages.
047 *  Pages are held in a directory structure:
048 *  <PRE>
049 *    Main.txt
050 *    Foobar.txt
051 *    OLD/
052 *       Main/
053 *          1.txt
054 *          2.txt
055 *          page.properties
056 *       Foobar/
057 *          page.properties
058 *  </PRE>
059 *
060 *  In this case, "Main" has three versions, and "Foobar" just one version.
061 *  <P>
062 *  The properties file contains the necessary metainformation (such as author)
063 *  information of the page.  DO NOT MESS WITH IT!
064 *
065 *  <P>
066 *  All files have ".txt" appended to make life easier for those
067 *  who insist on using Windows or other software which makes assumptions
068 *  on the files contents based on its name.
069 *
070 */
071public class VersioningFileProvider extends AbstractFileProvider {
072
073    private static final Logger     log = Logger.getLogger(VersioningFileProvider.class);
074
075    /** Name of the directory where the old versions are stored. */
076    public static final String      PAGEDIR      = "OLD";
077
078    /** Name of the property file which stores the metadata. */
079    public static final String      PROPERTYFILE = "page.properties";
080
081    private CachedProperties        m_cachedProperties;
082
083    /**
084     *  {@inheritDoc}
085     */
086    @Override
087    public void initialize( WikiEngine engine, Properties properties )
088        throws NoRequiredPropertyException,
089               IOException
090    {
091        super.initialize( engine, properties );
092        // some additional sanity checks :
093        File oldpages = new File(getPageDirectory(), PAGEDIR);
094        if (!oldpages.exists())
095        {
096            if (!oldpages.mkdirs())
097            {
098                throw new IOException("Failed to create page version directory " + oldpages.getAbsolutePath());
099            }
100        }
101        else
102        {
103            if (!oldpages.isDirectory())
104            {
105                throw new IOException("Page version directory is not a directory: " + oldpages.getAbsolutePath());
106            }
107            if (!oldpages.canWrite())
108            {
109                throw new IOException("Page version directory is not writable: " + oldpages.getAbsolutePath());
110            }
111        }
112        log.info("Using directory " + oldpages.getAbsolutePath() + " for storing old versions of pages");
113    }
114
115    /**
116     *  Returns the directory where the old versions of the pages
117     *  are being kept.
118     */
119    private File findOldPageDir( String page )
120    {
121        if( page == null )
122        {
123            throw new InternalWikiException("Page may NOT be null in the provider!");
124        }
125
126        File oldpages = new File( getPageDirectory(), PAGEDIR );
127
128        return new File( oldpages, mangleName(page) );
129    }
130
131    /**
132     *  Goes through the repository and decides which version is
133     *  the newest one in that directory.
134     *
135     *  @return Latest version number in the repository, or -1, if
136     *          there is no page in the repository.
137     */
138
139    // FIXME: This is relatively slow.
140    /*
141    private int findLatestVersion( String page )
142    {
143        File pageDir = findOldPageDir( page );
144
145        String[] pages = pageDir.list( new WikiFileFilter() );
146
147        if( pages == null )
148        {
149            return -1; // No such thing found.
150        }
151
152        int version = -1;
153
154        for( int i = 0; i < pages.length; i++ )
155        {
156            int cutpoint = pages[i].indexOf( '.' );
157            if( cutpoint > 0 )
158            {
159                String pageNum = pages[i].substring( 0, cutpoint );
160
161                try
162                {
163                    int res = Integer.parseInt( pageNum );
164
165                    if( res > version )
166                    {
167                        version = res;
168                    }
169                }
170                catch( NumberFormatException e ) {} // It's okay to skip these.
171            }
172        }
173
174        return version;
175    }
176*/
177    private int findLatestVersion( String page )
178    {
179        int version = -1;
180
181        try
182        {
183            Properties props = getPageProperties( page );
184
185            for( Iterator<Object> i = props.keySet().iterator(); i.hasNext(); )
186            {
187                String key = (String)i.next();
188
189                if( key.endsWith(".author") )
190                {
191                    int cutpoint = key.indexOf('.');
192                    if( cutpoint > 0 )
193                    {
194                        String pageNum = key.substring(0,cutpoint);
195
196                        try
197                        {
198                            int res = Integer.parseInt( pageNum );
199
200                            if( res > version )
201                            {
202                                version = res;
203                            }
204                        }
205                        catch( NumberFormatException e ) {} // It's okay to skip these.
206                    }
207                }
208            }
209        }
210        catch( IOException e )
211        {
212            log.error("Unable to figure out latest version - dying...",e);
213        }
214
215        return version;
216    }
217
218    /**
219     *  Reads page properties from the file system.
220     */
221    private Properties getPageProperties( final String page ) throws IOException {
222        final File propertyFile = new File( findOldPageDir(page), PROPERTYFILE );
223        if( propertyFile.exists() ) {
224            final long lastModified = propertyFile.lastModified();
225
226            //
227            //   The profiler showed that when calling the history of a page the propertyfile
228            //   was read just as much times as there were versions of that file. The loading
229            //   of a propertyfile is a cpu-intensive job. So now hold on to the last propertyfile
230            //   read because the next method will with a high probability ask for the same propertyfile.
231            //   The time it took to show a historypage with 267 versions dropped with 300%.
232            //
233
234            CachedProperties cp = m_cachedProperties;
235
236            if( cp != null && cp.m_page.equals( page ) && cp.m_lastModified == lastModified ) {
237                return cp.m_props;
238            }
239
240            try( InputStream in = new BufferedInputStream(new FileInputStream( propertyFile ) ) ) {
241                Properties props = new Properties();
242                props.load( in );
243                cp = new CachedProperties( page, props, lastModified );
244                m_cachedProperties = cp; // Atomic
245
246                return props;
247            }
248        }
249
250        return new Properties(); // Returns an empty object
251    }
252
253    /**
254     *  Writes the page properties back to the file system.
255     *  Note that it WILL overwrite any previous properties.
256     */
257    private void putPageProperties( final String page, final Properties properties ) throws IOException {
258        final File propertyFile = new File( findOldPageDir(page), PROPERTYFILE );
259        try( final OutputStream out = new FileOutputStream( propertyFile ) ) {
260            properties.store( out, " JSPWiki page properties for "+page+". DO NOT MODIFY!" );
261        }
262
263        // The profiler showed the probability was very high that when  calling for the history of
264        // a page the propertyfile would be read as much times as there were versions of that file.
265        // It is statistically likely the propertyfile will be examined many times before it is updated.
266        final CachedProperties cp = new CachedProperties( page, properties, propertyFile.lastModified() );
267        m_cachedProperties = cp; // Atomic
268    }
269
270    /**
271     *  Figures out the real version number of the page and also checks for its existence.
272     *
273     *  @throws NoSuchVersionException if there is no such version.
274     */
275    private int realVersion( String page, int requestedVersion ) throws NoSuchVersionException {
276        //
277        //  Quickly check for the most common case.
278        //
279        if( requestedVersion == WikiProvider.LATEST_VERSION )
280        {
281            return -1;
282        }
283
284        int latest = findLatestVersion(page);
285
286        if( requestedVersion == latest ||
287            (requestedVersion == 1 && latest == -1 ) )
288        {
289            return -1;
290        }
291        else if( requestedVersion <= 0 || requestedVersion > latest )
292        {
293            throw new NoSuchVersionException("Requested version "+requestedVersion+", but latest is "+latest );
294        }
295
296        return requestedVersion;
297    }
298
299    /**
300     *  {@inheritDoc}
301     */
302    @Override
303    public synchronized String getPageText( String page, int version )
304        throws ProviderException
305    {
306        File dir = findOldPageDir( page );
307
308        version = realVersion( page, version );
309        if( version == -1 )
310        {
311            // We can let the FileSystemProvider take care
312            // of these requests.
313            return super.getPageText( page, WikiPageProvider.LATEST_VERSION );
314        }
315
316        File pageFile = new File( dir, ""+version+FILE_EXT );
317
318        if( !pageFile.exists() )
319            throw new NoSuchVersionException("Version "+version+"does not exist.");
320
321        return readFile( pageFile );
322    }
323
324
325    // FIXME: Should this really be here?
326    private String readFile( final File pagedata ) throws ProviderException {
327        String result = null;
328        if( pagedata.exists() ) {
329            if( pagedata.canRead() ) {
330                try( final InputStream in = new FileInputStream( pagedata ) ) {
331                    result = FileUtil.readContents( in, m_encoding );
332                } catch( IOException e ) {
333                    log.error("Failed to read", e);
334                    throw new ProviderException("I/O error: "+e.getMessage());
335                }
336            } else {
337                log.warn("Failed to read page from '"+pagedata.getAbsolutePath()+"', possibly a permissions problem");
338                throw new ProviderException("I cannot read the requested page.");
339            }
340        } else {
341            // This is okay.
342            // FIXME: is it?
343            log.info("New page");
344        }
345
346        return result;
347    }
348
349    // FIXME: This method has no rollback whatsoever.
350
351    /*
352      This is how the page directory should look like:
353
354         version    pagedir       olddir
355          none       empty         empty
356           1         Main.txt (1)  empty
357           2         Main.txt (2)  1.txt
358           3         Main.txt (3)  1.txt, 2.txt
359    */
360    /**
361     *  {@inheritDoc}
362     */
363    @Override
364    public synchronized void putPageText( final WikiPage page, final String text ) throws ProviderException {
365        //
366        //  This is a bit complicated.  We'll first need to
367        //  copy the old file to be the newest file.
368        //
369        final int  latest  = findLatestVersion( page.getName() );
370        final File pageDir = findOldPageDir( page.getName() );
371        if( !pageDir.exists() ) {
372            pageDir.mkdirs();
373        }
374
375        try {
376            //
377            // Copy old data to safety, if one exists.
378            //
379            final File oldFile = findPage( page.getName() );
380
381            // Figure out which version should the old page be?
382            // Numbers should always start at 1.
383            // "most recent" = -1 ==> 1
384            // "first"       = 1  ==> 2
385
386            int versionNumber = (latest > 0) ? latest : 1;
387            final boolean firstUpdate = (versionNumber == 1);
388
389            if( oldFile != null && oldFile.exists() ) {
390                final File pageFile = new File( pageDir, versionNumber + FILE_EXT );
391                try( InputStream in = new BufferedInputStream( new FileInputStream( oldFile ) );
392                     OutputStream out = new BufferedOutputStream( new FileOutputStream( pageFile ) ) ) {
393                    FileUtil.copyContents( in, out );
394
395                    //
396                    // We need also to set the date, since we rely on this.
397                    //
398                    pageFile.setLastModified( oldFile.lastModified() );
399
400                    //
401                    // Kludge to make the property code to work properly.
402                    //
403                    versionNumber++;
404                }
405            }
406
407            //
408            //  Let superclass handler writing data to a new version.
409            //
410            super.putPageText( page, text );
411
412            //
413            //  Finally, write page version data.
414            //
415            // FIXME: No rollback available.
416            Properties props = getPageProperties( page.getName() );
417
418            String authorFirst = null;
419            // if the following file exists, we are NOT migrating from FileSystemProvider
420            File pagePropFile = new File(getPageDirectory() + File.separator + PAGEDIR + File.separator + mangleName(page.getName()) + File.separator + "page" + FileSystemProvider.PROP_EXT);
421            if( firstUpdate && ! pagePropFile.exists() ) {
422                // we might not yet have a versioned author because the
423                // old page was last maintained by FileSystemProvider
424                Properties props2 = getHeritagePageProperties( page.getName() );
425
426                // remember the simulated original author (or something)
427                // in the new properties
428                authorFirst = props2.getProperty( "1.author", "unknown" );
429                props.setProperty( "1.author", authorFirst );
430            }
431
432            String newAuthor = page.getAuthor();
433            if ( newAuthor == null )
434            {
435                newAuthor = ( authorFirst != null ) ? authorFirst : "unknown";
436            }
437            page.setAuthor(newAuthor);
438            props.setProperty( versionNumber + ".author", newAuthor );
439
440            String changeNote = (String) page.getAttribute(WikiPage.CHANGENOTE);
441            if( changeNote != null ) {
442                props.setProperty( versionNumber + ".changenote", changeNote );
443            }
444
445            // Get additional custom properties from page and add to props
446            getCustomProperties( page, props );
447            putPageProperties( page.getName(), props );
448        } catch( final IOException e ) {
449            log.error( "Saving failed", e );
450            throw new ProviderException("Could not save page text: "+e.getMessage());
451        }
452    }
453
454    /**
455     *  {@inheritDoc}
456     */
457    @Override
458    public WikiPage getPageInfo( String page, int version )
459        throws ProviderException
460    {
461        int latest = findLatestVersion(page);
462        int realVersion;
463
464        WikiPage p = null;
465
466        if( version == WikiPageProvider.LATEST_VERSION ||
467            version == latest ||
468            (version == 1 && latest == -1) )
469        {
470            //
471            // Yes, we need to talk to the top level directory
472            // to get this version.
473            //
474            // I am listening to Press Play On Tape's guitar version of
475            // the good old C64 "Wizardry" -tune at this moment.
476            // Oh, the memories...
477            //
478            realVersion = (latest >= 0) ? latest : 1;
479
480            p = super.getPageInfo( page, WikiPageProvider.LATEST_VERSION );
481
482            if( p != null )
483            {
484                p.setVersion( realVersion );
485            }
486        }
487        else
488        {
489            //
490            //  The file is not the most recent, so we'll need to
491            //  find it from the deep trenches of the "OLD" directory
492            //  structure.
493            //
494            realVersion = version;
495            File dir = findOldPageDir( page );
496
497            if( !dir.exists() || !dir.isDirectory() )
498            {
499                return null;
500            }
501
502            File file = new File( dir, version+FILE_EXT );
503
504            if( file.exists() )
505            {
506                p = new WikiPage( m_engine, page );
507
508                p.setLastModified( new Date(file.lastModified()) );
509                p.setVersion( version );
510            }
511        }
512
513        //
514        //  Get author and other metadata information
515        //  (Modification date has already been set.)
516        //
517        if( p != null )
518        {
519            try
520            {
521                Properties props = getPageProperties( page );
522                String author = props.getProperty( realVersion+".author" );
523                if ( author == null )
524                {
525                    // we might not have a versioned author because the
526                    // old page was last maintained by FileSystemProvider
527                    Properties props2 = getHeritagePageProperties( page );
528                    author = props2.getProperty( WikiPage.AUTHOR );
529                }
530                if ( author != null )
531                {
532                    p.setAuthor( author );
533                }
534
535                String changenote = props.getProperty( realVersion+".changenote" );
536                if( changenote != null ) p.setAttribute( WikiPage.CHANGENOTE, changenote );
537
538                // Set the props values to the page attributes
539                setCustomProperties(p, props);
540            }
541            catch( IOException e )
542            {
543                log.error( "Cannot get author for page"+page+": ", e );
544            }
545        }
546
547        return p;
548    }
549
550    /**
551     *  {@inheritDoc}
552     */
553    @Override
554    public boolean pageExists( String pageName, int version )
555    {
556        if (version == WikiPageProvider.LATEST_VERSION || version == findLatestVersion( pageName ) ) {
557            return pageExists(pageName);
558        }
559
560        File dir = findOldPageDir( pageName );
561
562        if( !dir.exists() || !dir.isDirectory() )
563        {
564            return false;
565        }
566
567        File file = new File( dir, version+FILE_EXT );
568
569        return file.exists();
570
571    }
572
573    /**
574     *  {@inheritDoc}
575     */
576     // FIXME: Does not get user information.
577    @Override
578    public List< WikiPage > getVersionHistory( String page ) throws ProviderException {
579        ArrayList<WikiPage> list = new ArrayList<>();
580        int latest = findLatestVersion( page );
581
582        // list.add( getPageInfo(page,WikiPageProvider.LATEST_VERSION) );
583
584        for( int i = latest; i > 0; i-- )
585        {
586            WikiPage info = getPageInfo( page, i );
587
588            if( info != null )
589            {
590                list.add( info );
591            }
592        }
593
594        return list;
595    }
596
597    /*
598     * Support for migration of simple properties created by the
599     * FileSystemProvider when coming under Versioning management.
600     * Simulate an initial version.
601     */
602    private Properties getHeritagePageProperties( final String page ) throws IOException {
603        final File propertyFile = new File( getPageDirectory(), mangleName( page ) + FileSystemProvider.PROP_EXT );
604        if ( propertyFile.exists() ) {
605            final long lastModified = propertyFile.lastModified();
606
607            CachedProperties cp = m_cachedProperties;
608            if ( cp != null && cp.m_page.equals(page) && cp.m_lastModified == lastModified ) {
609                return cp.m_props;
610            }
611
612            try( final InputStream in = new BufferedInputStream( new FileInputStream( propertyFile ) ) ) {
613                final Properties props = new Properties();
614                props.load( in );
615
616                final String originalAuthor = props.getProperty( WikiPage.AUTHOR );
617                if ( originalAuthor.length() > 0 ) {
618                    // simulate original author as if already versioned but put non-versioned property in special cache too
619                    props.setProperty( "1.author", originalAuthor );
620
621                    // The profiler showed the probability was very high that when calling for the history of a page the
622                    // propertyfile would be read as much times as there were versions of that file. It is statistically
623                    // likely the propertyfile will be examined many times before it is updated.
624                    cp = new CachedProperties( page, props, propertyFile.lastModified() );
625                    m_cachedProperties = cp; // Atomic
626                }
627
628                return props;
629            }
630        }
631
632        return new Properties(); // Returns an empty object
633    }
634
635    /**
636     *  Removes the relevant page directory under "OLD" -directory as well,
637     *  but does not remove any extra subdirectories from it.  It will only
638     *  touch those files that it thinks to be WikiPages.
639     *
640     *  @param page {@inheritDoc}
641     *  @throws {@inheritDoc}
642     */
643    // FIXME: Should log errors.
644    @Override
645    public void deletePage( String page )
646        throws ProviderException
647    {
648        super.deletePage( page );
649
650        File dir = findOldPageDir( page );
651
652        if( dir.exists() && dir.isDirectory() )
653        {
654            File[] files = dir.listFiles( new WikiFileFilter() );
655
656            for( int i = 0; i < files.length; i++ )
657            {
658                files[i].delete();
659            }
660
661            File propfile = new File( dir, PROPERTYFILE );
662
663            if( propfile.exists() )
664            {
665                propfile.delete();
666            }
667
668            dir.delete();
669        }
670    }
671
672    /**
673     *  {@inheritDoc}
674     *
675     *  Deleting versions has never really worked, JSPWiki assumes that version histories are "not gappy".
676     *  Using deleteVersion() is definitely not recommended.
677     */
678    @Override
679    public void deleteVersion( final String page, final int version ) throws ProviderException {
680        final File dir = findOldPageDir( page );
681        int latest = findLatestVersion( page );
682        if( version == WikiPageProvider.LATEST_VERSION ||
683            version == latest ||
684            (version == 1 && latest == -1) ) {
685            //
686            //  Delete the properties
687            //
688            try {
689                final Properties props = getPageProperties( page );
690                props.remove( ((latest > 0) ? latest : 1)+".author" );
691                putPageProperties( page, props );
692            } catch( final IOException e ) {
693                log.error("Unable to modify page properties",e);
694                throw new ProviderException("Could not modify page properties: " + e.getMessage());
695            }
696
697            // We can let the FileSystemProvider take care
698            // of the actual deletion
699            super.deleteVersion( page, WikiPageProvider.LATEST_VERSION );
700
701            //
702            //  Copy the old file to the new location
703            //
704            latest = findLatestVersion( page );
705
706            final File pageDir = findOldPageDir( page );
707            final File previousFile = new File( pageDir, latest + FILE_EXT );
708            final File pageFile = findPage(page);
709            try( final InputStream in = new BufferedInputStream( new FileInputStream( previousFile ) );
710                 final OutputStream out = new BufferedOutputStream( new FileOutputStream( pageFile ) ) ) {
711                if( previousFile.exists() ) {
712                    FileUtil.copyContents( in, out );
713                    //
714                    // We need also to set the date, since we rely on this.
715                    //
716                    pageFile.setLastModified( previousFile.lastModified() );
717                }
718            } catch( final IOException e ) {
719                log.fatal("Something wrong with the page directory - you may have just lost data!",e);
720            }
721
722            return;
723        }
724
725        final File pageFile = new File( dir, ""+version+FILE_EXT );
726
727        if( pageFile.exists() ) {
728            if( !pageFile.delete() ) {
729                log.error("Unable to delete page." + pageFile.getPath() );
730            }
731        } else {
732            throw new NoSuchVersionException("Page "+page+", version="+version);
733        }
734    }
735
736    /**
737     *  {@inheritDoc}
738     */
739    // FIXME: This is kinda slow, we should need to do this only once.
740    @Override
741    public Collection< WikiPage > getAllPages() throws ProviderException {
742        final Collection< WikiPage > pages = super.getAllPages();
743        final Collection< WikiPage > returnedPages = new ArrayList<>();
744        for( final WikiPage page : pages ) {
745            WikiPage info = getPageInfo( page.getName(), WikiProvider.LATEST_VERSION );
746            returnedPages.add( info );
747        }
748
749        return returnedPages;
750    }
751
752    /**
753     *  {@inheritDoc}
754     */
755    @Override
756    public String getProviderInfo()
757    {
758        return "";
759    }
760
761    /**
762     *  {@inheritDoc}
763     */
764    @Override
765    public void movePage( final String from, final String to ) {
766        // Move the file itself
767        final File fromFile = findPage( from );
768        final File toFile = findPage( to );
769        fromFile.renameTo( toFile );
770
771        // Move any old versions
772        final File fromOldDir = findOldPageDir( from );
773        final File toOldDir = findOldPageDir( to );
774        fromOldDir.renameTo( toOldDir );
775    }
776
777    /*
778     * The profiler showed that when calling the history of a page, the propertyfile was read just as many
779     * times as there were versions of that file. The loading of a propertyfile is a cpu-intensive job.
780     * This Class holds onto the last propertyfile read, because the probability is high that the next call
781     * will with ask for the same propertyfile. The time it took to show a historypage with 267 versions dropped
782     * by 300%. Although each propertyfile in a history could be cached, there is likely to be little performance
783     * gain over simply keeping the last one requested.
784     */
785    private static class CachedProperties {
786        String m_page;
787        Properties m_props;
788        long m_lastModified;
789
790        /*
791         * Because a Constructor is inherently synchronised, there is no need to synchronise the arguments.
792         *
793         * @param engine WikiEngine instance
794         * @param props  Properties to use for initialization
795         */
796        public CachedProperties( final String pageName, final Properties props, final long lastModified ) {
797            if ( pageName == null ) {
798                throw new NullPointerException ( "pageName must not be null!" );
799            }
800            this.m_page = pageName;
801            if ( props == null ) {
802                throw new NullPointerException ( "properties must not be null!" );
803            }
804            m_props = props;
805            this.m_lastModified = lastModified;
806        }
807    }
808}