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.providers; 020 021import org.apache.logging.log4j.LogManager; 022import org.apache.logging.log4j.Logger; 023import org.apache.wiki.InternalWikiException; 024import org.apache.wiki.api.core.Engine; 025import org.apache.wiki.api.core.Page; 026import org.apache.wiki.api.exceptions.NoRequiredPropertyException; 027import org.apache.wiki.api.exceptions.ProviderException; 028import org.apache.wiki.api.providers.PageProvider; 029import org.apache.wiki.api.providers.WikiProvider; 030import org.apache.wiki.api.spi.Wiki; 031import org.apache.wiki.util.FileUtil; 032 033import java.io.BufferedInputStream; 034import java.io.BufferedOutputStream; 035import java.io.File; 036import java.io.IOException; 037import java.io.InputStream; 038import java.io.OutputStream; 039import java.nio.file.Files; 040import java.util.ArrayList; 041import java.util.Collection; 042import java.util.Date; 043import java.util.List; 044import java.util.Properties; 045 046/** 047 * Provides a simple directory based repository for Wiki pages. 048 * Pages are held in a directory structure: 049 * <PRE> 050 * Main.txt 051 * Foobar.txt 052 * OLD/ 053 * Main/ 054 * 1.txt 055 * 2.txt 056 * page.properties 057 * Foobar/ 058 * page.properties 059 * </PRE> 060 * 061 * In this case, "Main" has three versions, and "Foobar" just one version. 062 * <P> 063 * The properties file contains the necessary metainformation (such as author) 064 * information of the page. DO NOT MESS WITH IT! 065 * 066 * <P> 067 * All files have ".txt" appended to make life easier for those 068 * who insist on using Windows or other software which makes assumptions 069 * on the files contents based on its name. 070 * 071 */ 072public class VersioningFileProvider extends AbstractFileProvider { 073 074 private static final Logger LOG = LogManager.getLogger( VersioningFileProvider.class ); 075 076 /** Name of the directory where the old versions are stored. */ 077 public static final String PAGEDIR = "OLD"; 078 079 /** Name of the property file which stores the metadata. */ 080 public static final String PROPERTYFILE = "page.properties"; 081 082 private CachedProperties m_cachedProperties; 083 084 /** 085 * {@inheritDoc} 086 */ 087 @Override 088 public void initialize( final Engine engine, final Properties properties ) throws NoRequiredPropertyException, IOException { 089 super.initialize( engine, properties ); 090 // some additional sanity checks : 091 final File oldpages = new File( getPageDirectory(), PAGEDIR ); 092 if( !oldpages.exists() ) { 093 if( !oldpages.mkdirs() ) { 094 throw new IOException( "Failed to create page version directory " + oldpages.getAbsolutePath() ); 095 } 096 } else { 097 if( !oldpages.isDirectory() ) { 098 throw new IOException( "Page version directory is not a directory: " + oldpages.getAbsolutePath() ); 099 } 100 if( !oldpages.canWrite() ) { 101 throw new IOException( "Page version directory is not writable: " + oldpages.getAbsolutePath() ); 102 } 103 } 104 LOG.info( "Using directory " + oldpages.getAbsolutePath() + " for storing old versions of pages" ); 105 } 106 107 /** 108 * Returns the directory where the old versions of the pages 109 * are being kept. 110 */ 111 private File findOldPageDir( final String page ) { 112 if( page == null ) { 113 throw new InternalWikiException( "Page may NOT be null in the provider!" ); 114 } 115 final File oldpages = new File( getPageDirectory(), PAGEDIR ); 116 return new File( oldpages, mangleName( page ) ); 117 } 118 119 /** 120 * Goes through the repository and decides which version is the newest one in that directory. 121 * 122 * @return Latest version number in the repository, or -1, if there is no page in the repository. 123 */ 124 125 // FIXME: This is relatively slow. 126 /* 127 private int findLatestVersion( String page ) 128 { 129 File pageDir = findOldPageDir( page ); 130 131 String[] pages = pageDir.list( new WikiFileFilter() ); 132 133 if( pages == null ) 134 { 135 return -1; // No such thing found. 136 } 137 138 int version = -1; 139 140 for( int i = 0; i < pages.length; i++ ) 141 { 142 int cutpoint = pages[i].indexOf( '.' ); 143 if( cutpoint > 0 ) 144 { 145 String pageNum = pages[i].substring( 0, cutpoint ); 146 147 try 148 { 149 int res = Integer.parseInt( pageNum ); 150 151 if( res > version ) 152 { 153 version = res; 154 } 155 } 156 catch( NumberFormatException e ) {} // It's okay to skip these. 157 } 158 } 159 160 return version; 161 } 162*/ 163 private int findLatestVersion( final String page ) { 164 int version = -1; 165 166 try { 167 final Properties props = getPageProperties( page ); 168 169 for( final Object o : props.keySet() ) { 170 final String key = ( String )o; 171 if( key.endsWith( ".author" ) ) { 172 final int cutpoint = key.indexOf( '.' ); 173 if( cutpoint > 0 ) { 174 final String pageNum = key.substring( 0, cutpoint ); 175 176 try { 177 final int res = Integer.parseInt( pageNum ); 178 179 if( res > version ) { 180 version = res; 181 } 182 } catch( final NumberFormatException e ) { 183 } // It's okay to skip these. 184 } 185 } 186 } 187 } catch( final IOException e ) { 188 LOG.error( "Unable to figure out latest version - dying...", e ); 189 } 190 191 return version; 192 } 193 194 /** 195 * Reads page properties from the file system. 196 */ 197 private Properties getPageProperties( final String page ) throws IOException { 198 final File propertyFile = new File( findOldPageDir(page), PROPERTYFILE ); 199 if( propertyFile.exists() ) { 200 final long lastModified = propertyFile.lastModified(); 201 202 // 203 // The profiler showed that when calling the history of a page the propertyfile 204 // was read just as much times as there were versions of that file. The loading 205 // of a propertyfile is a cpu-intensive job. So now hold on to the last propertyfile 206 // read because the next method will with a high probability ask for the same propertyfile. 207 // The time it took to show a historypage with 267 versions dropped with 300%. 208 // 209 210 CachedProperties cp = m_cachedProperties; 211 212 if( cp != null && cp.m_page.equals( page ) && cp.m_lastModified == lastModified ) { 213 return cp.m_props; 214 } 215 216 try( final InputStream in = new BufferedInputStream( Files.newInputStream( propertyFile.toPath() ) ) ) { 217 final Properties props = new Properties(); 218 props.load( in ); 219 cp = new CachedProperties( page, props, lastModified ); 220 m_cachedProperties = cp; // Atomic 221 222 return props; 223 } 224 } 225 226 return new Properties(); // Returns an empty object 227 } 228 229 /** 230 * Writes the page properties back to the file system. 231 * Note that it WILL overwrite any previous properties. 232 */ 233 private void putPageProperties( final String page, final Properties properties ) throws IOException { 234 final File propertyFile = new File( findOldPageDir(page), PROPERTYFILE ); 235 try( final OutputStream out = Files.newOutputStream( propertyFile.toPath() ) ) { 236 properties.store( out, " JSPWiki page properties for "+page+". DO NOT MODIFY!" ); 237 } 238 239 // The profiler showed the probability was very high that when calling for the history of 240 // a page the propertyfile would be read as much times as there were versions of that file. 241 // It is statistically likely the propertyfile will be examined many times before it is updated. 242 m_cachedProperties = new CachedProperties( page, properties, propertyFile.lastModified() ); // Atomic 243 } 244 245 /** 246 * Figures out the real version number of the page and also checks for its existence. 247 * 248 * @throws NoSuchVersionException if there is no such version. 249 */ 250 private int realVersion( final String page, final int requestedVersion ) throws NoSuchVersionException { 251 // Quickly check for the most common case. 252 if( requestedVersion == WikiProvider.LATEST_VERSION ) { 253 return -1; 254 } 255 256 final int latest = findLatestVersion(page); 257 258 if( requestedVersion == latest || (requestedVersion == 1 && latest == -1 ) ) { 259 return -1; 260 } else if( requestedVersion <= 0 || requestedVersion > latest ) { 261 throw new NoSuchVersionException( "Requested version " + requestedVersion + ", but latest is " + latest ); 262 } 263 264 return requestedVersion; 265 } 266 267 /** 268 * {@inheritDoc} 269 */ 270 @Override 271 public synchronized String getPageText( final String page, int version ) throws ProviderException { 272 final File dir = findOldPageDir( page ); 273 274 version = realVersion( page, version ); 275 if( version == -1 ) { 276 // We can let the FileSystemProvider take care of these requests. 277 return super.getPageText( page, PageProvider.LATEST_VERSION ); 278 } 279 280 final File pageFile = new File( dir, ""+version+FILE_EXT ); 281 if( !pageFile.exists() ) { 282 throw new NoSuchVersionException("Version "+version+"does not exist."); 283 } 284 285 return readFile( pageFile ); 286 } 287 288 289 // FIXME: Should this really be here? 290 private String readFile( final File pagedata ) throws ProviderException { 291 String result = null; 292 if( pagedata.exists() ) { 293 if( pagedata.canRead() ) { 294 try( final InputStream in = Files.newInputStream( pagedata.toPath() ) ) { 295 result = FileUtil.readContents( in, m_encoding ); 296 } catch( final IOException e ) { 297 LOG.error("Failed to read", e); 298 throw new ProviderException("I/O error: "+e.getMessage()); 299 } 300 } else { 301 LOG.warn("Failed to read page from '"+pagedata.getAbsolutePath()+"', possibly a permissions problem"); 302 throw new ProviderException("I cannot read the requested page."); 303 } 304 } else { 305 // This is okay. 306 // FIXME: is it? 307 LOG.info("New page"); 308 } 309 310 return result; 311 } 312 313 // FIXME: This method has no rollback whatsoever. 314 315 /* 316 This is how the page directory should look like: 317 318 version pagedir olddir 319 none empty empty 320 1 Main.txt (1) empty 321 2 Main.txt (2) 1.txt 322 3 Main.txt (3) 1.txt, 2.txt 323 */ 324 /** 325 * {@inheritDoc} 326 */ 327 @Override 328 public synchronized void putPageText( final Page page, final String text ) throws ProviderException { 329 // This is a bit complicated. We'll first need to copy the old file to be the newest file. 330 final int latest = findLatestVersion( page.getName() ); 331 final File pageDir = findOldPageDir( page.getName() ); 332 if( !pageDir.exists() ) { 333 pageDir.mkdirs(); 334 } 335 336 try { 337 // Copy old data to safety, if one exists. 338 final File oldFile = findPage( page.getName() ); 339 340 // Figure out which version should the old page be? Numbers should always start at 1. 341 // "most recent" = -1 ==> 1 342 // "first" = 1 ==> 2 343 int versionNumber = (latest > 0) ? latest : 1; 344 final boolean firstUpdate = (versionNumber == 1); 345 346 if( oldFile != null && oldFile.exists() ) { 347 final File pageFile = new File( pageDir, versionNumber + FILE_EXT ); 348 try( final InputStream in = new BufferedInputStream( Files.newInputStream( oldFile.toPath() ) ); 349 final OutputStream out = new BufferedOutputStream( Files.newOutputStream( pageFile.toPath() ) ) ) { 350 FileUtil.copyContents( in, out ); 351 352 // We need also to set the date, since we rely on this. 353 pageFile.setLastModified( oldFile.lastModified() ); 354 355 // Kludge to make the property code to work properly. 356 versionNumber++; 357 } 358 } 359 360 // Let superclass handler writing data to a new version. 361 super.putPageText( page, text ); 362 363 // Finally, write page version data. 364 // FIXME: No rollback available. 365 final Properties props = getPageProperties( page.getName() ); 366 367 String authorFirst = null; 368 // if the following file exists, we are NOT migrating from FileSystemProvider 369 final File pagePropFile = new File(getPageDirectory() + File.separator + PAGEDIR + File.separator + mangleName(page.getName()) + File.separator + "page" + FileSystemProvider.PROP_EXT); 370 if( firstUpdate && ! pagePropFile.exists() ) { 371 // we might not yet have a versioned author because the old page was last maintained by FileSystemProvider 372 final Properties props2 = getHeritagePageProperties( page.getName() ); 373 374 // remember the simulated original author (or something) in the new properties 375 authorFirst = props2.getProperty( "1.author", "unknown" ); 376 props.setProperty( "1.author", authorFirst ); 377 } 378 379 String newAuthor = page.getAuthor(); 380 if ( newAuthor == null ) { 381 newAuthor = ( authorFirst != null ) ? authorFirst : "unknown"; 382 } 383 page.setAuthor(newAuthor); 384 props.setProperty( versionNumber + ".author", newAuthor ); 385 386 final String changeNote = page.getAttribute( Page.CHANGENOTE ); 387 if( changeNote != null ) { 388 props.setProperty( versionNumber + ".changenote", changeNote ); 389 } 390 391 // Get additional custom properties from page and add to props 392 getCustomProperties( page, props ); 393 putPageProperties( page.getName(), props ); 394 } catch( final IOException e ) { 395 LOG.error( "Saving failed", e ); 396 throw new ProviderException("Could not save page text: "+e.getMessage()); 397 } 398 } 399 400 /** 401 * {@inheritDoc} 402 */ 403 @Override 404 public Page getPageInfo( final String page, final int version ) throws ProviderException { 405 final int latest = findLatestVersion( page ); 406 final int realVersion; 407 408 Page p = null; 409 410 if( version == PageProvider.LATEST_VERSION || version == latest || (version == 1 && latest == -1) ) { 411 // 412 // Yes, we need to talk to the top level directory to get this version. 413 // 414 // I am listening to Press Play On Tape's guitar version of the good old C64 "Wizardry" -tune at this moment. 415 // Oh, the memories... 416 // 417 realVersion = (latest >= 0) ? latest : 1; 418 419 p = super.getPageInfo( page, PageProvider.LATEST_VERSION ); 420 421 if( p != null ) { 422 p.setVersion( realVersion ); 423 } 424 } else { 425 // The file is not the most recent, so we'll need to find it from the deep trenches of the "OLD" directory structure. 426 realVersion = version; 427 final File dir = findOldPageDir( page ); 428 if( !dir.exists() || !dir.isDirectory() ) { 429 return null; 430 } 431 432 final File file = new File( dir, version + FILE_EXT ); 433 if( file.exists() ) { 434 p = Wiki.contents().page( m_engine, page ); 435 436 p.setLastModified( new Date( file.lastModified() ) ); 437 p.setVersion( version ); 438 } 439 } 440 441 // Get author and other metadata information (Modification date has already been set.) 442 if( p != null ) { 443 try { 444 final Properties props = getPageProperties( page ); 445 String author = props.getProperty( realVersion + ".author" ); 446 if( author == null ) { 447 // we might not have a versioned author because the old page was last maintained by FileSystemProvider 448 final Properties props2 = getHeritagePageProperties( page ); 449 author = props2.getProperty( Page.AUTHOR ); 450 } 451 if( author != null ) { 452 p.setAuthor( author ); 453 } 454 455 final String changenote = props.getProperty( realVersion + ".changenote" ); 456 if( changenote != null ) { 457 p.setAttribute( Page.CHANGENOTE, changenote ); 458 } 459 460 // Set the props values to the page attributes 461 setCustomProperties( p, props ); 462 } catch( final IOException e ) { 463 LOG.error( "Cannot get author for page" + page + ": ", e ); 464 } 465 } 466 467 return p; 468 } 469 470 /** 471 * {@inheritDoc} 472 */ 473 @Override 474 public boolean pageExists( final String pageName, final int version ) { 475 if (version == PageProvider.LATEST_VERSION || version == findLatestVersion( pageName ) ) { 476 return pageExists(pageName); 477 } 478 479 final File dir = findOldPageDir( pageName ); 480 if( !dir.exists() || !dir.isDirectory() ) { 481 return false; 482 } 483 484 return new File( dir, version + FILE_EXT ).exists(); 485 } 486 487 /** 488 * {@inheritDoc} 489 */ 490 // FIXME: Does not get user information. 491 @Override 492 public List< Page > getVersionHistory( final String page ) throws ProviderException { 493 final ArrayList< Page > list = new ArrayList<>(); 494 final int latest = findLatestVersion( page ); 495 for( int i = latest; i > 0; i-- ) { 496 final Page info = getPageInfo( page, i ); 497 if( info != null ) { 498 list.add( info ); 499 } 500 } 501 502 return list; 503 } 504 505 /* 506 * Support for migration of simple properties created by the FileSystemProvider when coming under Versioning management. 507 * Simulate an initial version. 508 */ 509 private Properties getHeritagePageProperties( final String page ) throws IOException { 510 final File propertyFile = new File( getPageDirectory(), mangleName( page ) + FileSystemProvider.PROP_EXT ); 511 if ( propertyFile.exists() ) { 512 final long lastModified = propertyFile.lastModified(); 513 514 CachedProperties cp = m_cachedProperties; 515 if ( cp != null && cp.m_page.equals(page) && cp.m_lastModified == lastModified ) { 516 return cp.m_props; 517 } 518 519 try( final InputStream in = new BufferedInputStream( Files.newInputStream( propertyFile.toPath() ) ) ) { 520 final Properties props = new Properties(); 521 props.load( in ); 522 523 final String originalAuthor = props.getProperty( Page.AUTHOR ); 524 if ( !originalAuthor.isEmpty() ) { 525 // simulate original author as if already versioned but put non-versioned property in special cache too 526 props.setProperty( "1.author", originalAuthor ); 527 528 // The profiler showed the probability was very high that when calling for the history of a page the 529 // propertyfile would be read as much times as there were versions of that file. It is statistically 530 // likely the propertyfile will be examined many times before it is updated. 531 cp = new CachedProperties( page, props, propertyFile.lastModified() ); 532 m_cachedProperties = cp; // Atomic 533 } 534 535 return props; 536 } 537 } 538 539 return new Properties(); // Returns an empty object 540 } 541 542 /** 543 * Removes the relevant page directory under "OLD" -directory as well, but does not remove any extra subdirectories from it. 544 * It will only touch those files that it thinks to be WikiPages. 545 * 546 * @param page {@inheritDoc} 547 * @throws {@inheritDoc} 548 */ 549 // FIXME: Should log errors. 550 @Override 551 public void deletePage( final String page ) throws ProviderException { 552 super.deletePage( page ); 553 final File dir = findOldPageDir( page ); 554 if( dir.exists() && dir.isDirectory() ) { 555 final File[] files = dir.listFiles( new WikiFileFilter() ); 556 for( final File file : files ) { 557 file.delete(); 558 } 559 560 final File propfile = new File( dir, PROPERTYFILE ); 561 if( propfile.exists() ) { 562 propfile.delete(); 563 } 564 565 dir.delete(); 566 } 567 } 568 569 /** 570 * {@inheritDoc} 571 * 572 * Deleting versions has never really worked, JSPWiki assumes that version histories are "not gappy". Using deleteVersion() is 573 * definitely not recommended. 574 */ 575 @Override 576 public void deleteVersion( final String page, final int version ) throws ProviderException { 577 final File dir = findOldPageDir( page ); 578 int latest = findLatestVersion( page ); 579 if( version == PageProvider.LATEST_VERSION || 580 version == latest || 581 (version == 1 && latest == -1) ) { 582 // Delete the properties 583 try { 584 final Properties props = getPageProperties( page ); 585 props.remove( ((latest > 0) ? latest : 1)+".author" ); 586 putPageProperties( page, props ); 587 } catch( final IOException e ) { 588 LOG.error("Unable to modify page properties",e); 589 throw new ProviderException("Could not modify page properties: " + e.getMessage()); 590 } 591 592 // We can let the FileSystemProvider take care of the actual deletion 593 super.deleteVersion( page, PageProvider.LATEST_VERSION ); 594 595 // Copy the old file to the new location 596 latest = findLatestVersion( page ); 597 598 final File pageDir = findOldPageDir( page ); 599 final File previousFile = new File( pageDir, latest + FILE_EXT ); 600 final File pageFile = findPage(page); 601 try( final InputStream in = new BufferedInputStream( Files.newInputStream( previousFile.toPath() ) ); 602 final OutputStream out = new BufferedOutputStream( Files.newOutputStream( pageFile.toPath() ) ) ) { 603 if( previousFile.exists() ) { 604 FileUtil.copyContents( in, out ); 605 // We need also to set the date, since we rely on this. 606 pageFile.setLastModified( previousFile.lastModified() ); 607 } 608 } catch( final IOException e ) { 609 LOG.fatal("Something wrong with the page directory - you may have just lost data!",e); 610 } 611 612 return; 613 } 614 615 final File pageFile = new File( dir, ""+version+FILE_EXT ); 616 if( pageFile.exists() ) { 617 if( !pageFile.delete() ) { 618 LOG.error("Unable to delete page." + pageFile.getPath() ); 619 } 620 } else { 621 throw new NoSuchVersionException("Page "+page+", version="+version); 622 } 623 } 624 625 /** 626 * {@inheritDoc} 627 */ 628 // FIXME: This is kinda slow, we should need to do this only once. 629 @Override 630 public Collection< Page > getAllPages() throws ProviderException { 631 final Collection< Page > pages = super.getAllPages(); 632 final Collection< Page > returnedPages = new ArrayList<>(); 633 for( final Page page : pages ) { 634 final Page info = getPageInfo( page.getName(), WikiProvider.LATEST_VERSION ); 635 returnedPages.add( info ); 636 } 637 638 return returnedPages; 639 } 640 641 /** 642 * {@inheritDoc} 643 */ 644 @Override 645 public String getProviderInfo() 646 { 647 return ""; 648 } 649 650 /** 651 * {@inheritDoc} 652 */ 653 @Override 654 public void movePage( final String from, final String to ) { 655 // Move the file itself 656 final File fromFile = findPage( from ); 657 final File toFile = findPage( to ); 658 fromFile.renameTo( toFile ); 659 660 // Move any old versions 661 final File fromOldDir = findOldPageDir( from ); 662 final File toOldDir = findOldPageDir( to ); 663 fromOldDir.renameTo( toOldDir ); 664 } 665 666 /* 667 * The profiler showed that when calling the history of a page, the propertyfile was read just as many 668 * times as there were versions of that file. The loading of a propertyfile is a cpu-intensive job. 669 * This Class holds onto the last propertyfile read, because the probability is high that the next call 670 * will with ask for the same propertyfile. The time it took to show a historypage with 267 versions dropped 671 * by 300%. Although each propertyfile in a history could be cached, there is likely to be little performance 672 * gain over simply keeping the last one requested. 673 */ 674 private static class CachedProperties { 675 String m_page; 676 Properties m_props; 677 long m_lastModified; 678 679 /** 680 * Because a Constructor is inherently synchronised, there is no need to synchronise the arguments. 681 * 682 * @param pageName page name 683 * @param props Properties to use for initialization 684 * @param lastModified last modified date 685 */ 686 public CachedProperties( final String pageName, final Properties props, final long lastModified ) { 687 if ( pageName == null ) { 688 throw new NullPointerException ( "pageName must not be null!" ); 689 } 690 this.m_page = pageName; 691 if ( props == null ) { 692 throw new NullPointerException ( "properties must not be null!" ); 693 } 694 m_props = props; 695 this.m_lastModified = lastModified; 696 } 697 } 698 699}