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}