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}