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.commons.lang3.StringUtils; 022import org.apache.logging.log4j.LogManager; 023import org.apache.logging.log4j.Logger; 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.search.QueryItem; 031import org.apache.wiki.api.search.SearchResult; 032import org.apache.wiki.api.spi.Wiki; 033import org.apache.wiki.search.SearchMatcher; 034import org.apache.wiki.search.SearchResultComparator; 035import org.apache.wiki.util.FileUtil; 036import org.apache.wiki.util.TextUtil; 037 038import java.io.File; 039import java.io.FileNotFoundException; 040import java.io.FilenameFilter; 041import java.io.IOException; 042import java.io.InputStream; 043import java.io.OutputStreamWriter; 044import java.io.PrintWriter; 045import java.nio.charset.StandardCharsets; 046import java.nio.file.Files; 047import java.util.ArrayList; 048import java.util.Collection; 049import java.util.Date; 050import java.util.Enumeration; 051import java.util.List; 052import java.util.Map; 053import java.util.Properties; 054import java.util.TreeSet; 055import org.apache.commons.lang3.SystemUtils; 056 057 058/** 059 * Provides a simple directory based repository for Wiki pages. 060 * <P> 061 * All files have ".txt" appended to make life easier for those who insist on using Windows or other software which makes assumptions 062 * on the files contents based on its name. 063 * <p> 064 * This class functions as a superclass to all file based providers. 065 * 066 * @since 2.1.21. 067 */ 068public abstract class AbstractFileProvider implements PageProvider { 069 070 private static final Logger LOG = LogManager.getLogger(AbstractFileProvider.class); 071 private String m_pageDirectory = "/tmp/"; 072 protected String m_encoding; 073 074 protected Engine m_engine; 075 076 public static final String PROP_CUSTOMPROP_MAXLIMIT = "custom.pageproperty.max.allowed"; 077 public static final String PROP_CUSTOMPROP_MAXKEYLENGTH = "custom.pageproperty.key.length"; 078 public static final String PROP_CUSTOMPROP_MAXVALUELENGTH = "custom.pageproperty.value.length"; 079 080 public static final int DEFAULT_MAX_PROPLIMIT = 200; 081 public static final int DEFAULT_MAX_PROPKEYLENGTH = 255; 082 public static final int DEFAULT_MAX_PROPVALUELENGTH = 4096; 083 084 /** This parameter limits the number of custom page properties allowed on a page */ 085 public static int MAX_PROPLIMIT = DEFAULT_MAX_PROPLIMIT; 086 087 /** 088 * This number limits the length of a custom page property key length. The default value here designed with future JDBC providers in mind. 089 */ 090 public static int MAX_PROPKEYLENGTH = DEFAULT_MAX_PROPKEYLENGTH; 091 092 /** 093 * This number limits the length of a custom page property value length. The default value here designed with future JDBC providers in mind. 094 */ 095 public static int MAX_PROPVALUELENGTH = DEFAULT_MAX_PROPVALUELENGTH; 096 097 /** Name of the property that defines where page directories are. */ 098 public static final String PROP_PAGEDIR = "jspwiki.fileSystemProvider.pageDir"; 099 100 /** 101 * All files should have this extension to be recognized as JSPWiki files. We default to .txt, because that is probably easiest for 102 * Windows users, and guarantees correct handling. 103 */ 104 public static final String FILE_EXT = ".txt"; 105 106 /** The default encoding. */ 107 public static final String DEFAULT_ENCODING = StandardCharsets.ISO_8859_1.toString(); 108 109 private boolean m_windowsHackNeeded; 110 111 /** 112 * {@inheritDoc} 113 * @throws FileNotFoundException If the specified page directory does not exist. 114 * @throws IOException In case the specified page directory is a file, not a directory. 115 */ 116 @Override 117 public void initialize( final Engine engine, final Properties properties ) throws NoRequiredPropertyException, IOException, FileNotFoundException { 118 LOG.debug( "Initing FileSystemProvider" ); 119 m_pageDirectory = TextUtil.getCanonicalFilePathProperty( properties, PROP_PAGEDIR, 120 System.getProperty( "user.home" ) + File.separator + "jspwiki-files" ); 121 122 final File f = new File( m_pageDirectory ); 123 124 if( !f.exists() ) { 125 if( !f.mkdirs() ) { 126 throw new IOException( "Failed to create page directory " + f.getAbsolutePath() + " , please check property " + PROP_PAGEDIR ); 127 } 128 } else { 129 if( !f.isDirectory() ) { 130 throw new IOException( "Page directory is not a directory: " + f.getAbsolutePath() ); 131 } 132 if( !f.canWrite() ) { 133 throw new IOException( "Page directory is not writable: " + f.getAbsolutePath() ); 134 } 135 } 136 137 m_engine = engine; 138 m_encoding = properties.getProperty( Engine.PROP_ENCODING, DEFAULT_ENCODING ); 139 m_windowsHackNeeded = SystemUtils.IS_OS_WINDOWS; 140 141 MAX_PROPLIMIT = TextUtil.getIntegerProperty( properties, PROP_CUSTOMPROP_MAXLIMIT, DEFAULT_MAX_PROPLIMIT ); 142 MAX_PROPKEYLENGTH = TextUtil.getIntegerProperty( properties, PROP_CUSTOMPROP_MAXKEYLENGTH, DEFAULT_MAX_PROPKEYLENGTH ); 143 MAX_PROPVALUELENGTH = TextUtil.getIntegerProperty( properties, PROP_CUSTOMPROP_MAXVALUELENGTH, DEFAULT_MAX_PROPVALUELENGTH ); 144 145 LOG.info( "Wikipages are read from '" + m_pageDirectory + "'" ); 146 } 147 148 149 String getPageDirectory() 150 { 151 return m_pageDirectory; 152 } 153 154 private static final String[] WINDOWS_DEVICE_NAMES = { 155 "con", "prn", "nul", "aux", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", 156 "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9" 157 }; 158 159 /** 160 * This makes sure that the queried page name is still readable by the file system. For example, all XML entities 161 * and slashes are encoded with the percent notation. 162 * 163 * @param pagename The name to mangle 164 * @return The mangled name. 165 */ 166 protected String mangleName( String pagename ) { 167 pagename = TextUtil.urlEncode( pagename, m_encoding ); 168 pagename = TextUtil.replaceString( pagename, "/", "%2F" ); 169 170 // Names which start with a dot must be escaped to prevent problems. Since we use URL encoding, this is invisible in our unescaping. 171 if( pagename.startsWith( "." ) ) { 172 pagename = "%2E" + pagename.substring( 1 ); 173 } 174 175 if( m_windowsHackNeeded ) { 176 final String pn = pagename.toLowerCase(); 177 final StringBuilder pagenameBuilder = new StringBuilder(pagename); 178 for( final String windowsDeviceName : WINDOWS_DEVICE_NAMES ) { 179 if( windowsDeviceName.equals( pn ) ) { 180 pagenameBuilder.insert(0, "$$$"); 181 } 182 } 183 pagename = pagenameBuilder.toString(); 184 } 185 186 return pagename; 187 } 188 189 /** 190 * This makes the reverse of mangleName. 191 * 192 * @param filename The filename to unmangle 193 * @return The unmangled name. 194 */ 195 protected String unmangleName( String filename ) { 196 // The exception should never happen. 197 if( m_windowsHackNeeded && filename.startsWith( "$$$" ) && filename.length() > 3 ) { 198 filename = filename.substring( 3 ); 199 } 200 201 return TextUtil.urlDecode( filename, m_encoding ); 202 } 203 204 /** 205 * Finds a Wiki page from the page repository. 206 * 207 * @param page The name of the page. 208 * @return A File to the page. May be null. 209 */ 210 protected File findPage( final String page ) { 211 return new File( m_pageDirectory, mangleName( page ) + FILE_EXT ); 212 } 213 214 /** 215 * {@inheritDoc} 216 */ 217 @Override 218 public boolean pageExists( final String page ) { 219 return findPage( page ).exists(); 220 } 221 222 /** 223 * {@inheritDoc} 224 */ 225 @Override 226 public boolean pageExists( final String page, final int version ) { 227 return pageExists( page ); 228 } 229 230 /** 231 * This implementation just returns the current version, as filesystem does not provide versioning information for now. 232 * 233 * {@inheritDoc} 234 */ 235 @Override 236 public String getPageText( final String page, final int version ) throws ProviderException { 237 return getPageText( page ); 238 } 239 240 /** 241 * Read the text directly from the correct file. 242 */ 243 private String getPageText( final String page ) { 244 String result = null; 245 final File pagedata = findPage( page ); 246 if( pagedata.exists() ) { 247 if( pagedata.canRead() ) { 248 try( final InputStream in = Files.newInputStream( pagedata.toPath() ) ) { 249 result = FileUtil.readContents( in, m_encoding ); 250 } catch( final IOException e ) { 251 LOG.error( "Failed to read", e ); 252 } 253 } else { 254 LOG.warn( "Failed to read page '" + page + "' from '" + pagedata.getAbsolutePath() + "', possibly a permissions problem" ); 255 } 256 } else { 257 // This is okay. 258 LOG.info( "New page '" + page + "'" ); 259 } 260 261 return deobfuscate(result); 262 } 263 264 /** 265 * An api hook to provide obfuscation of data before it's written to disk. 266 * Useful for wiring in your own custom encryption mechanism if so desired. 267 * @since 3.0.0 268 * 269 * @param pageContent 270 * @return 271 */ 272 public String obfuscate(String pageContent) { 273 return pageContent; 274 } 275 276 /** 277 * An api hook to provide deobfuscation of data before it's written to disk. 278 * Useful for wiring in your own custom decryption mechanism if so desired. 279 * @since 3.0.0 280 * @param pageContent 281 * @return 282 */ 283 public String deobfuscate(String pageContent) { 284 return pageContent; 285 } 286 287 /** 288 * {@inheritDoc} 289 */ 290 @Override 291 public void putPageText( final Page page, final String text ) throws ProviderException { 292 final File file = findPage( page.getName() ); 293 try( final PrintWriter out = new PrintWriter( new OutputStreamWriter( Files.newOutputStream( file.toPath() ), m_encoding ) ) ) { 294 out.print( obfuscate(text) ); 295 } catch( final IOException e ) { 296 LOG.error( "Saving failed", e ); 297 } 298 } 299 300 /** 301 * {@inheritDoc} 302 */ 303 @Override 304 public Collection< Page > getAllPages() throws ProviderException { 305 LOG.debug("Getting all pages..."); 306 final ArrayList< Page > set = new ArrayList<>(); 307 final File wikipagedir = new File( m_pageDirectory ); 308 final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() ); 309 310 if( wikipages == null ) { 311 LOG.error("Wikipages directory '" + m_pageDirectory + "' does not exist! Please check " + PROP_PAGEDIR + " in jspwiki.properties."); 312 throw new ProviderException( "Page directory does not exist" ); 313 } 314 315 for( final File wikipage : wikipages ) { 316 final String wikiname = wikipage.getName(); 317 final int cutpoint = wikiname.lastIndexOf( FILE_EXT ); 318 final Page page = getPageInfo( unmangleName( wikiname.substring( 0, cutpoint ) ), PageProvider.LATEST_VERSION ); 319 if( page == null ) { 320 // This should not really happen. 321 // FIXME: Should we throw an exception here? 322 LOG.error( "Page " + wikiname + " was found in directory listing, but could not be located individually." ); 323 continue; 324 } 325 326 set.add( page ); 327 } 328 329 return set; 330 } 331 332 /** 333 * Does not work. 334 * 335 * @param date {@inheritDoc} 336 * @return {@inheritDoc} 337 */ 338 @Override 339 public Collection< Page > getAllChangedSince( final Date date ) 340 { 341 return new ArrayList<>(); // FIXME 342 } 343 344 /** 345 * {@inheritDoc} 346 */ 347 @Override 348 public int getPageCount() { 349 final File wikipagedir = new File( m_pageDirectory ); 350 final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() ); 351 return wikipages != null ? wikipages.length : 0; 352 } 353 354 /** 355 * Iterates through all WikiPages, matches them against the given query, and returns a Collection of SearchResult objects. 356 * 357 * {@inheritDoc} 358 */ 359 @Override 360 public Collection< SearchResult > findPages( final QueryItem[] query ) { 361 final File wikipagedir = new File( m_pageDirectory ); 362 final TreeSet< SearchResult > res = new TreeSet<>( new SearchResultComparator() ); 363 final SearchMatcher matcher = new SearchMatcher( m_engine, query ); 364 final File[] wikipages = wikipagedir.listFiles( new WikiFileFilter() ); 365 366 if( wikipages != null ) { 367 for( final File wikipage : wikipages ) { 368 final String filename = wikipage.getName(); 369 final int cutpoint = filename.lastIndexOf( FILE_EXT ); 370 final String wikiname = unmangleName( filename.substring( 0, cutpoint ) ); 371 try( final InputStream input = Files.newInputStream( wikipage.toPath() ) ) { 372 final String pagetext = FileUtil.readContents( input, m_encoding ); 373 final SearchResult comparison = matcher.matchPageContent( wikiname, pagetext ); 374 if( comparison != null ) { 375 res.add( comparison ); 376 } 377 } catch( final IOException e ) { 378 LOG.error( "Failed to read " + filename, e ); 379 } 380 } 381 } 382 383 return res; 384 } 385 386 /** 387 * Always returns the latest version, since FileSystemProvider 388 * does not support versioning. 389 * 390 * {@inheritDoc} 391 */ 392 @Override 393 public Page getPageInfo( final String page, final int version ) throws ProviderException { 394 final File file = findPage( page ); 395 if( !file.exists() ) { 396 return null; 397 } 398 399 final Page p = Wiki.contents().page( m_engine, page ); 400 p.setLastModified( new Date( file.lastModified() ) ); 401 402 return p; 403 } 404 405 /** 406 * The FileSystemProvider provides only one version. 407 * 408 * {@inheritDoc} 409 */ 410 @Override 411 public List< Page > getVersionHistory( final String page ) throws ProviderException { 412 final ArrayList< Page > list = new ArrayList<>(); 413 list.add( getPageInfo( page, PageProvider.LATEST_VERSION ) ); 414 415 return list; 416 } 417 418 /** 419 * {@inheritDoc} 420 */ 421 @Override 422 public String getProviderInfo() 423 { 424 return ""; 425 } 426 427 /** 428 * {@inheritDoc} 429 */ 430 @Override 431 public void deleteVersion( final String pageName, final int version ) throws ProviderException { 432 if( version == WikiProvider.LATEST_VERSION ) { 433 final File f = findPage( pageName ); 434 f.delete(); 435 } 436 } 437 438 /** 439 * {@inheritDoc} 440 */ 441 @Override 442 public void deletePage( final String pageName ) throws ProviderException { 443 final File f = findPage( pageName ); 444 f.delete(); 445 } 446 447 /** 448 * Set the custom properties provided into the given page. 449 * 450 * @since 2.10.2 451 */ 452 protected void setCustomProperties( final Page page, final Properties properties ) { 453 final Enumeration< ? > propertyNames = properties.propertyNames(); 454 while( propertyNames.hasMoreElements() ) { 455 final String key = ( String )propertyNames.nextElement(); 456 if( !key.equals( Page.AUTHOR ) && !key.equals( Page.CHANGENOTE ) && !key.equals( Page.VIEWCOUNT ) ) { 457 page.setAttribute( key, properties.get( key ) ); 458 } 459 } 460 } 461 462 /** 463 * Get custom properties using {@link #addCustomProperties(Page, Properties)}, validate them using {@link #validateCustomPageProperties(Properties)} 464 * and add them to default properties provided 465 * 466 * @since 2.10.2 467 */ 468 protected void getCustomProperties( final Page page, final Properties defaultProperties ) throws IOException { 469 final Properties customPageProperties = addCustomProperties( page, defaultProperties ); 470 validateCustomPageProperties( customPageProperties ); 471 defaultProperties.putAll( customPageProperties ); 472 } 473 474 /** 475 * By default all page attributes that start with "@" are returned as custom properties. 476 * This can be overwritten by custom FileSystemProviders to save additional properties. 477 * CustomPageProperties are validated by {@link #validateCustomPageProperties(Properties)} 478 * 479 * @since 2.10.2 480 * @param page the current page 481 * @param props the default properties of this page 482 * @return default implementation returns empty Properties. 483 */ 484 protected Properties addCustomProperties( final Page page, final Properties props ) { 485 final Properties customProperties = new Properties(); 486 if( page != null ) { 487 final Map< String, Object > atts = page.getAttributes(); 488 for( final String key : atts.keySet() ) { 489 final Object value = atts.get( key ); 490 if( key.startsWith( "@" ) && value != null ) { 491 customProperties.put( key, value.toString() ); 492 } 493 } 494 495 } 496 return customProperties; 497 } 498 499 /** 500 * Default validation, validates that key and value is ASCII <code>StringUtils.isAsciiPrintable()</code> and within lengths set up in jspwiki-custom.properties. 501 * This can be overwritten by custom FileSystemProviders to validate additional properties 502 * See https://issues.apache.org/jira/browse/JSPWIKI-856 503 * @since 2.10.2 504 * @param customProperties the custom page properties being added 505 */ 506 protected void validateCustomPageProperties( final Properties customProperties ) throws IOException { 507 // Default validation rules 508 if( customProperties != null && !customProperties.isEmpty() ) { 509 if( customProperties.size() > MAX_PROPLIMIT ) { 510 throw new IOException( "Too many custom properties. You are adding " + customProperties.size() + ", but max limit is " + MAX_PROPLIMIT ); 511 } 512 final Enumeration< ? > propertyNames = customProperties.propertyNames(); 513 while( propertyNames.hasMoreElements() ) { 514 final String key = ( String )propertyNames.nextElement(); 515 final String value = ( String )customProperties.get( key ); 516 if( key != null ) { 517 if( key.length() > MAX_PROPKEYLENGTH ) { 518 throw new IOException( "Custom property key " + key + " is too long. Max allowed length is " + MAX_PROPKEYLENGTH ); 519 } 520 if( !StringUtils.isAsciiPrintable( key ) ) { 521 throw new IOException( "Custom property key " + key + " is not simple ASCII!" ); 522 } 523 } 524 if( value != null ) { 525 if( value.length() > MAX_PROPVALUELENGTH ) { 526 throw new IOException( "Custom property key " + key + " has value that is too long. Value=" + value + ". Max allowed length is " + MAX_PROPVALUELENGTH ); 527 } 528 if( !StringUtils.isAsciiPrintable( value ) ) { 529 throw new IOException( "Custom property key " + key + " has value that is not simple ASCII! Value=" + value ); 530 } 531 } 532 } 533 } 534 } 535 536 /** 537 * A simple filter which filters only those filenames which correspond to the 538 * file extension used. 539 */ 540 public static class WikiFileFilter implements FilenameFilter { 541 /** 542 * {@inheritDoc} 543 */ 544 @Override 545 public boolean accept( final File dir, final String name ) { 546 return name.endsWith( FILE_EXT ); 547 } 548 } 549 550}