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