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}