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 && 0 < body.length() && 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 && 0 < value.length() && !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}