    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.plugin;
    020
    021import org.apache.commons.lang3.StringUtils;
    022import org.apache.commons.lang3.math.NumberUtils;
    023import org.apache.logging.log4j.LogManager;
    024import org.apache.logging.log4j.Logger;
    025import org.apache.oro.text.GlobCompiler;
    026import org.apache.oro.text.regex.MalformedPatternException;
    027import org.apache.oro.text.regex.Pattern;
    028import org.apache.oro.text.regex.PatternCompiler;
    029import org.apache.oro.text.regex.PatternMatcher;
    030import org.apache.oro.text.regex.Perl5Matcher;
    031import org.apache.wiki.WikiBackgroundThread;
    032import org.apache.wiki.api.core.Context;
    033import org.apache.wiki.api.core.ContextEnum;
    034import org.apache.wiki.api.core.Engine;
    035import org.apache.wiki.api.core.Page;
    036import org.apache.wiki.api.exceptions.PluginException;
    037import org.apache.wiki.api.plugin.InitializablePlugin;
    038import org.apache.wiki.api.plugin.Plugin;
    039import org.apache.wiki.event.WikiEngineEvent;
    040import org.apache.wiki.event.WikiEvent;
    041import org.apache.wiki.event.WikiEventListener;
    042import org.apache.wiki.event.WikiPageEvent;
    043import org.apache.wiki.event.WikiPageRenameEvent;
    044import org.apache.wiki.references.ReferenceManager;
    045import org.apache.wiki.render.RenderingManager;
    046import org.apache.wiki.util.TextUtil;
    047
    048import java.io.File;
    049import java.io.IOException;
    050import java.io.InputStream;
    051import java.io.OutputStream;
    052import java.nio.file.Files;
    053import java.text.MessageFormat;
    054import java.util.Collection;
    055import java.util.Comparator;
    056import java.util.HashSet;
    057import java.util.Iterator;
    058import java.util.Map;
    059import java.util.Map.Entry;
    060import java.util.Properties;
    061import java.util.TreeMap;
    062
    063
    064/**
    065 * This plugin counts the number of times a page has been viewed.<br/>
    066 * Parameters:
    067 * <ul>
    068 * <li>count=yes|no</li>
    069 * <li>show=none|count|list</li>
    070 * <li>entries=maximum number of list entries to be returned</li>
    071 * <li>min=minimum page count to be listed</li>
    072 * <li>max=maximum page count to be listed</li>
    073 * <li>sort=name|count</li>
    074 * </ul>
    075 * Default values:<br/>
    076 * <code>show=none  sort=name</code>
    077 *
    078 * @since 2.8
    079 */
    080public class PageViewPlugin extends AbstractReferralPlugin implements Plugin, InitializablePlugin {
    081
    082    private static final Logger LOG = LogManager.getLogger( PageViewPlugin.class );
    083
    084    /** The page view manager. */
    085    private static PageViewManager c_singleton;
    086
    087    /** Constant for the 'count' parameter / value. */
    088    private static final String PARAM_COUNT = "count";
    089
    090    /** Name of the 'entries' parameter. */
    091    private static final String PARAM_MAX_ENTRIES = "entries";
    092
    093    /** Name of the 'max' parameter. */
    094    private static final String PARAM_MAX_COUNT = "max";
    095
    096    /** Name of the 'min' parameter. */
    097    private static final String PARAM_MIN_COUNT = "min";
    098
    099    /** Name of the 'refer' parameter. */
    100    private static final String PARAM_REFER = "refer";
    101
    102    /** Name of the 'sort' parameter. */
    103    private static final String PARAM_SORT = "sort";
    104
    105    /** Constant for the 'none' parameter value. */
    106    private static final String STR_NONE = "none";
    107
    108    /** Constant for the 'list' parameter value. */
    109    private static final String STR_LIST = "list";
    110
    111    /** Constant for the 'yes' parameter value. */
    112    private static final String STR_YES = "yes";
    113
    114    /** Constant for empty string. */
    115    private static final String STR_EMPTY = "";
    116
    117    /** Constant for Wiki markup separator. */
    118    private static final String STR_SEPARATOR = "----";
    119
    120    /** Constant for comma-separated list separator. */
    121    private static final String STR_COMMA = ",";
    122
    123    /** Constant for no-op glob expression. */
    124    private static final String STR_GLOBSTAR = "*";
    125
    126    /** Constant for file storage. */
    127    private static final String COUNTER_PAGE = "PageCount.txt";
    128
    129    /** Constant for storage interval in seconds. */
    130    private static final int STORAGE_INTERVAL = 60;
    131
    132    /**
    133     * Initialize the PageViewPlugin and its singleton.
    134     *
    135     * @param engine The wiki engine.
    136     */
    137    @Override
    138    public void initialize( final Engine engine ) {
    139        LOG.info( "initializing PageViewPlugin" );
    140        synchronized( this ) {
    141            if( c_singleton == null ) {
    142                c_singleton = new PageViewManager();
    143            }
    144            c_singleton.initialize( engine );
    145        }
    146    }
    147
    148    /**
    149     * Cleanup the singleton reference.
    150     */
    151    private void cleanup() {
    152        LOG.info( "cleaning up PageView Manager" );
    153        c_singleton = null;
    154    }
    155
    156    /**
    157     *  {@inheritDoc}
    158     */
    159    @Override
    160    public String execute( final Context context, final Map< String, String > params ) throws PluginException {
    161        final PageViewManager manager = c_singleton;
    162        String result = STR_EMPTY;
    163
    164        if( manager != null ) {
    165            result = manager.execute( context, params );
    166        }
    167
    168        return result;
    169    }
    170
    171    /**
    172     * Page view manager, handling all storage.
    173     */
    174    public final class PageViewManager implements WikiEventListener {
    175        /** Are we initialized? */
    176        private boolean m_initialized;
    177
    178        /** The page counters. */
    179        private Map<String, Counter> m_counters;
    180
    181        /** The page counters in storage format. */
    182        private Properties m_storage;
    183
    184        /** Are all changes stored? */
    185        private boolean m_dirty;
    186
    187        /** The page count storage background thread. */
    188        private Thread m_pageCountSaveThread;
    189
    190        /** The work directory. */
    191        private String m_workDir;
    192
    193        /** Comparator for descending sort on page count. */
    194        private final Comparator< Object > m_compareCountDescending = ( o1, o2 ) -> {
    195            final int v1 = getCount( o1 );
    196            final int v2 = getCount( o2 );
    197            return ( v1 == v2 ) ? ( ( String )o1 ).compareTo( ( String )o2 ) : ( v1 < v2 ) ? 1 : -1;
    198        };
    199
    200        /**
    201         * Initialize the page view manager.
    202         *
    203         * @param engine The wiki engine.
    204         */
    205        public synchronized void initialize( final Engine engine ) {
    206            LOG.info( "initializing PageView Manager" );
    207            m_workDir = engine.getWorkDir();
    208            engine.addWikiEventListener( this );
    209            if( m_counters == null ) {
    210                // Load the counters into a collection
    211                m_storage = new Properties();
    212                m_counters = new TreeMap<>();
    213
    214                loadCounters();
    215            }
    216
    217            // backup counters every 5 minutes
    218            if( m_pageCountSaveThread == null ) {
    219                m_pageCountSaveThread = new CounterSaveThread( engine, 5 * STORAGE_INTERVAL, this );
    220                m_pageCountSaveThread.start();
    221            }
    222
    223            m_initialized = true;
    224        }
    225
    226        /**
    227         * Handle the shutdown event via the page counter thread.
    228         */
    229        private synchronized void handleShutdown() {
    230            LOG.info( "handleShutdown: The counter store thread was shut down." );
    231
    232            cleanup();
    233
    234            if( m_counters != null ) {
    235
    236                m_dirty = true;
    237                storeCounters();
    238
    239                m_counters.clear();
    240                m_counters = null;
    241
    242                m_storage.clear();
    243                m_storage = null;
    244            }
    245
    246            m_initialized = false;
    247
    248            m_pageCountSaveThread = null;
    249        }
    250
    251        /**
    252         * Inspect wiki events for shutdown.
    253         *
    254         * @param event The wiki event to inspect.
    255         */
    256        @Override
    257        public void actionPerformed( final WikiEvent event ) {
    258            if( event instanceof WikiEngineEvent ) {
    259                if( event.getType() == WikiEngineEvent.SHUTDOWN ) {
    260                    LOG.info( "Detected wiki engine shutdown" );
    261                    handleShutdown();
    262                }
    263            } else if( ( event instanceof WikiPageRenameEvent ) && ( event.getType() == WikiPageRenameEvent.PAGE_RENAMED ) ) {
    264                final String oldPageName = ( ( WikiPageRenameEvent )event ).getOldPageName();
    265                final String newPageName = ( ( WikiPageRenameEvent )event ).getNewPageName();
    266                final Counter oldCounter = m_counters.get( oldPageName );
    267                if( oldCounter != null ) {
    268                    m_storage.remove( oldPageName );
    269                    m_counters.put( newPageName, oldCounter );
    270                    m_storage.setProperty( newPageName, oldCounter.toString() );
    271                    m_counters.remove( oldPageName );
    272                    m_dirty = true;
    273                }
    274            } else if( ( event instanceof WikiPageEvent ) && ( event.getType() == WikiPageEvent.PAGE_DELETED ) ) {
    275                final String pageName = ( ( WikiPageEvent )event ).getPageName();
    276                m_storage.remove( pageName );
    277                m_counters.remove( pageName );
    278            }
    279        }
    280
    281        /**
    282         * Count a page hit, present a pages' counter or output a list of page counts.
    283         *
    284         * @param context the wiki context
    285         * @param params the plugin parameters
    286         * @return String Wiki page snippet
    287         * @throws PluginException Malformed pattern parameter.
    288         */
    289        public String execute( final Context context, final Map< String, String > params ) throws PluginException {
    290            final Engine engine = context.getEngine();
    291            final Page page = context.getPage();
    292            String result = STR_EMPTY;
    293
    294            if( page != null ) {
    295                // get parameters
    296                final String pagename = page.getName();
    297                String count = params.get( PARAM_COUNT );
    298                final String show = params.get( PARAM_SHOW );
    299                int entries = TextUtil.parseIntParameter( params.get( PARAM_MAX_ENTRIES ), Integer.MAX_VALUE );
    300                final int max = TextUtil.parseIntParameter( params.get( PARAM_MAX_COUNT ), Integer.MAX_VALUE );
    301                final int min = TextUtil.parseIntParameter( params.get( PARAM_MIN_COUNT ), Integer.MIN_VALUE );
    302                final String sort = params.get( PARAM_SORT );
    303                final String body = params.get( DefaultPluginManager.PARAM_BODY );
    304                final Pattern[] exclude = compileGlobs( PARAM_EXCLUDE, params.get( PARAM_EXCLUDE ) );
    305                final Pattern[] include = compileGlobs( PARAM_INCLUDE, params.get( PARAM_INCLUDE ) );
    306                final Pattern[] refer = compileGlobs( PARAM_REFER, params.get( PARAM_REFER ) );
    307                final PatternMatcher matcher = (null != exclude || null != include || null != refer) ? new Perl5Matcher() : null;
    308                boolean increment = false;
    309
    310                // increment counter?
    311                if( STR_YES.equals( count ) ) {
    312                    increment = true;
    313                } else {
    314                    count = null;
    315                }
    316
    317                // default increment counter?
    318                if( ( show == null || STR_NONE.equals( show ) ) && count == null ) {
    319                    increment = true;
    320                }
    321
    322                // filter on referring pages?
    323                Collection< String > referrers = null;
    324
    325                if( refer != null ) {
    326                    final ReferenceManager refManager = engine.getManager( ReferenceManager.class );
    327                    for( final String name : refManager.findCreated() ) {
    328                        boolean use = false;
    329                        for( int n = 0; !use && n < refer.length; n++ ) {
    330                            use = matcher.matches( name, refer[ n ] );
    331                        }
    332
    333                        if( use ) {
    334                            final Collection< String > refs = engine.getManager( ReferenceManager.class ).findReferrers( name );
    335                            if( refs != null && !refs.isEmpty() ) {
    336                                if( referrers == null ) {
    337                                    referrers = new HashSet<>();
    338                                }
    339                                referrers.addAll( refs );
    340                            }
    341                        }
    342                    }
    343                }
    344
    345                synchronized( this ) {
    346                    Counter counter = m_counters.get( pagename );
    347
    348                    // only count in view mode, keep storage values in sync
    349                    if( increment && ContextEnum.PAGE_VIEW.getRequestContext().equalsIgnoreCase( context.getRequestContext() ) ) {
    350                        if( counter == null ) {
    351                            counter = new Counter();
    352                            m_counters.put( pagename, counter );
    353                        }
    354                        counter.increment();
    355                        m_storage.setProperty( pagename, counter.toString() );
    356                        m_dirty = true;
    357                    }
    358
    359                    if( show == null || STR_NONE.equals( show ) ) {
    360                        // nothing to show
    361
    362                    } else if( PARAM_COUNT.equals( show ) ) {
    363                        // show page count
    364                        if( counter == null ) {
    365                            counter = new Counter();
    366                            m_counters.put( pagename, counter );
    367                            m_storage.setProperty( pagename, counter.toString() );
    368                            m_dirty = true;
    369                        }
    370                        result = counter.toString();
    371
    372                    } else if( body != null && !body.isEmpty() && STR_LIST.equals( show ) ) {
    373                        // show list of counts
    374                        String header = STR_EMPTY;
    375                        String line = body;
    376                        String footer = STR_EMPTY;
    377                        int start = body.indexOf( STR_SEPARATOR );
    378
    379                        // split body into header, line, footer on ---- separator
    380                        if( 0 < start ) {
    381                            header = body.substring( 0, start );
    382                            start = skipWhitespace( start + STR_SEPARATOR.length(), body );
    383                            int end = body.indexOf( STR_SEPARATOR, start );
    384                            if( start >= end ) {
    385                                line = body.substring( start );
    386                            } else {
    387                                line = body.substring( start, end );
    388                                end = skipWhitespace( end + STR_SEPARATOR.length(), body );
    389                                footer = body.substring( end );
    390                            }
    391                        }
    392
    393                        // sort on name or count?
    394                        Map< String, Counter > sorted = m_counters;
    395                        if( PARAM_COUNT.equals( sort ) ) {
    396                            sorted = new TreeMap<>( m_compareCountDescending );
    397                            sorted.putAll( m_counters );
    398                        }
    399
    400                        // build a messagebuffer with the list in wiki markup
    401                        final StringBuffer buf = new StringBuffer( header );
    402                        final MessageFormat fmt = new MessageFormat( line );
    403                        final Object[] args = new Object[] { pagename, STR_EMPTY, STR_EMPTY };
    404                        final Iterator< Entry< String, Counter > > iter = sorted.entrySet().iterator();
    405
    406                        while( 0 < entries && iter.hasNext() ) {
    407                            final Entry< String, Counter > entry = iter.next();
    408                            final String name = entry.getKey();
    409
    410                            // check minimum/maximum count
    411                            final int value = entry.getValue().getValue();
    412                            boolean use = min <= value && value <= max;
    413
    414                            // did we specify a refer-to page?
    415                            if( use && referrers != null ) {
    416                                use = referrers.contains( name );
    417                            }
    418
    419                            // did we specify what pages to include?
    420                            if( use && include != null ) {
    421                                use = false;
    422
    423                                for( int n = 0; !use && n < include.length; n++ ) {
    424                                    use = matcher.matches( name, include[ n ] );
    425                                }
    426                            }
    427
    428                            // did we specify what pages to exclude?
    429                            if( use && null != exclude ) {
    430                                for( int n = 0; use && n < exclude.length; n++ ) {
    431                                    use = !matcher.matches( name, exclude[ n ] );
    432                                }
    433                            }
    434
    435                            if( use ) {
    436                                args[ 1 ] = engine.getManager( RenderingManager.class ).beautifyTitle( name );
    437                                args[ 2 ] = entry.getValue();
    438
    439                                fmt.format( args, buf, null );
    440
    441                                entries--;
    442                            }
    443                        }
    444                        buf.append( footer );
    445
    446                        // let the engine render the list
    447                        result = engine.getManager( RenderingManager.class ).textToHTML( context, buf.toString() );
    448                    }
    449                }
    450            }
    451            return result;
    452        }
    453
    454        /**
    455         * Compile regexp parameter.
    456         *
    457         * @param name The name of the parameter.
    458         * @param value The parameter value.
    459         * @return Pattern[] The compiled patterns, or <code>null</code>.
    460         * @throws PluginException On malformed patterns.
    461         */
    462        private Pattern[] compileGlobs( final String name, final String value ) throws PluginException {
    463            Pattern[] result = null;
    464            if( value != null && !value.isEmpty() && !STR_GLOBSTAR.equals( value ) ) {
    465                try {
    466                    final PatternCompiler pc = new GlobCompiler();
    467                    final String[] ptrns = StringUtils.split( value, STR_COMMA );
    468                    result = new Pattern[ ptrns.length ];
    469
    470                    for( int n = 0; n < ptrns.length; n++ ) {
    471                        result[ n ] = pc.compile( ptrns[ n ] );
    472                    }
    473                } catch( final MalformedPatternException e ) {
    474                    throw new PluginException( "Parameter " + name + " has a malformed pattern: " + e.getMessage() );
    475                }
    476            }
    477
    478            return result;
    479        }
    480
    481        /**
    482         * Adjust offset skipping whitespace.
    483         *
    484         * @param offset The offset in value to adjust.
    485         * @param value String in which offset points.
    486         * @return int Adjusted offset into value.
    487         */
    488        private int skipWhitespace( int offset, final String value ) {
    489            while( Character.isWhitespace( value.charAt( offset ) ) ) {
    490                offset++;
    491            }
    492            return offset;
    493        }
    494
    495        /**
    496         * Retrieve a page count.
    497         *
    498         * @return int The page count for the given key.
    499         * @param key the key for the Counter
    500         */
    501        int getCount( final Object key )
    502        {
    503            return m_counters.get( key ).getValue();
    504        }
    505
    506        /**
    507         * Load the page view counters from file.
    508         */
    509        private void loadCounters() {
    510            if( m_counters != null && m_storage != null ) {
    511                LOG.info( "Loading counters." );
    512                synchronized( this ) {
    513                    try( final InputStream fis = Files.newInputStream( new File( m_workDir, COUNTER_PAGE ).toPath() ) ) {
    514                        m_storage.load( fis );
    515                    } catch( final IOException ioe ) {
    516                        LOG.error( "Can't load page counter store: " + ioe.getMessage() + " , will create a new one!" );
    517                    }
    518
    519                    // Copy the collection into a sorted map
    520                    for( final Entry< ?, ? > entry : m_storage.entrySet() ) {
    521                        m_counters.put( ( String )entry.getKey(), new Counter( ( String )entry.getValue() ) );
    522                    }
    523
    524                    LOG.info( "Loaded " + m_counters.size() + " counter values." );
    525                }
    526            }
    527        }
    528
    529        /**
    530         * Save the page view counters to file.
    531         */
    532        void storeCounters() {
    533            if( m_counters != null && m_storage != null && m_dirty ) {
    534                LOG.info( "Storing " + m_counters.size() + " counter values." );
    535                synchronized( this ) {
    536                    // Write out the collection of counters
    537                    try( final OutputStream fos = Files.newOutputStream( new File( m_workDir, COUNTER_PAGE ).toPath() ) ) {
    538                        m_storage.store( fos, "\n# The number of times each page has been viewed.\n# Do not modify.\n" );
    539                        fos.flush();
    540
    541                        m_dirty = false;
    542                    } catch( final IOException ioe ) {
    543                        LOG.error( "Couldn't store counters values: " + ioe.getMessage() );
    544                    }
    545                }
    546            }
    547        }
    548
    549        /**
    550         * Is the given thread still current?
    551         *
    552         * @param thrd thread that can be the current background thread.
    553         * @return boolean <code>true</code> if the thread is still the current background thread.
    554         */
    555        private synchronized boolean isRunning( final Thread thrd )
    556        {
    557            return m_initialized && thrd == m_pageCountSaveThread;
    558        }
    559
    560    }
    561
    562    /** Counter for page hits collection. */
    563    private static final class Counter {
    564
    565        /** The count value. */
    566        private int m_count;
    567
    568        /**
    569         * Create a new counter.
    570         */
    571        public Counter() {
    572        }
    573
    574        /**
    575         * Create and initialize a new counter.
    576         *
    577         * @param value Count value.
    578         */
    579        public Counter( final String value )
    580        {
    581            setValue( value );
    582        }
    583
    584        /**
    585         * Increment counter.
    586         */
    587        public void increment()
    588        {
    589            m_count++;
    590        }
    591
    592        /**
    593         * Get the count value.
    594         *
    595         * @return int
    596         */
    597        public int getValue()
    598        {
    599            return m_count;
    600        }
    601
    602        /**
    603         * Set the count value.
    604         *
    605         * @param value String representation of the count.
    606         */
    607        public void setValue( final String value )
    608        {
    609            m_count = NumberUtils.toInt( value );
    610        }
    611
    612        /**
    613         * @return String representation of the count.
    614         */
    615        @Override
    616        public String toString()
    617        {
    618            return String.valueOf( m_count );
    619        }
    620
    621    }
    622
    623    /**
    624     * Background thread storing the page counters.
    625     */
    626    static final class CounterSaveThread extends WikiBackgroundThread {
    627
    628        /** The page view manager. */
    629        private final PageViewManager m_manager;
    630
    631        /**
    632         * Create a wiki background thread to store the page counters.
    633         *
    634         * @param engine The wiki engine.
    635         * @param interval Delay in seconds between saves.
    636         * @param pageViewManager page view manager.
    637         */
    638        public CounterSaveThread( final Engine engine, final int interval, final PageViewManager pageViewManager ) {
    639            super( engine, interval );
    640            if( pageViewManager == null ) {
    641                throw new IllegalArgumentException( "Manager cannot be null" );
    642            }
    643
    644            m_manager = pageViewManager;
    645        }
    646
    647        /**
    648         * Save the page counters to file.
    649         */
    650        @Override
    651        public void backgroundTask() {
    652            if( m_manager.isRunning( this ) ) {
    653                m_manager.storeCounters();
    654            }
    655        }
    656    }
    657}