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.attachment; 020 021import org.apache.commons.fileupload.FileItem; 022import org.apache.commons.fileupload.FileItemFactory; 023import org.apache.commons.fileupload.FileUploadException; 024import org.apache.commons.fileupload.ProgressListener; 025import org.apache.commons.fileupload.disk.DiskFileItemFactory; 026import org.apache.commons.fileupload.servlet.ServletFileUpload; 027import org.apache.logging.log4j.LogManager; 028import org.apache.logging.log4j.Logger; 029import org.apache.wiki.api.core.Attachment; 030import org.apache.wiki.api.core.Context; 031import org.apache.wiki.api.core.ContextEnum; 032import org.apache.wiki.api.core.Engine; 033import org.apache.wiki.api.core.Page; 034import org.apache.wiki.api.core.Session; 035import org.apache.wiki.api.exceptions.ProviderException; 036import org.apache.wiki.api.exceptions.RedirectException; 037import org.apache.wiki.api.exceptions.WikiException; 038import org.apache.wiki.api.providers.WikiProvider; 039import org.apache.wiki.api.spi.Wiki; 040import org.apache.wiki.auth.AuthorizationManager; 041import org.apache.wiki.auth.permissions.PermissionFactory; 042import org.apache.wiki.i18n.InternationalizationManager; 043import org.apache.wiki.preferences.Preferences; 044import org.apache.wiki.ui.progress.ProgressItem; 045import org.apache.wiki.ui.progress.ProgressManager; 046import org.apache.wiki.util.HttpUtil; 047import org.apache.wiki.util.TextUtil; 048 049import javax.servlet.ServletConfig; 050import javax.servlet.ServletContext; 051import javax.servlet.ServletException; 052import javax.servlet.http.HttpServlet; 053import javax.servlet.http.HttpServletRequest; 054import javax.servlet.http.HttpServletResponse; 055import java.io.File; 056import java.io.IOException; 057import java.io.InputStream; 058import java.io.OutputStream; 059import java.net.SocketException; 060import java.nio.charset.StandardCharsets; 061import java.security.Permission; 062import java.security.Principal; 063import java.util.ArrayList; 064import java.util.List; 065import java.util.Properties; 066 067 068/** 069 * This is the chief JSPWiki attachment management servlet. It is used for 070 * both uploading new content and downloading old content. It can handle 071 * most common cases, e.g. check for modifications and return 304's as necessary. 072 * <p> 073 * Authentication is done using JSPWiki's normal AAA framework. 074 * <p> 075 * This servlet is also capable of managing dynamically created attachments. 076 * 077 * 078 * @since 1.9.45. 079 */ 080public class AttachmentServlet extends HttpServlet { 081 082 private static final int BUFFER_SIZE = 8192; 083 084 private static final long serialVersionUID = 3257282552187531320L; 085 086 private Engine m_engine; 087 private static final Logger log = LogManager.getLogger( AttachmentServlet.class ); 088 089 private static final String HDR_VERSION = "version"; 090 // private static final String HDR_NAME = "page"; 091 092 /** Default expiry period is 1 day */ 093 protected static final long DEFAULT_EXPIRY = 1 * 24 * 60 * 60 * 1000; 094 095 /** 096 * The maximum size that an attachment can be. 097 */ 098 private int m_maxSize = Integer.MAX_VALUE; 099 100 /** 101 * List of attachment types which are allowed 102 */ 103 104 private String[] m_allowedPatterns; 105 106 private String[] m_forbiddenPatterns; 107 108 // 109 // Not static as DateFormat objects are not thread safe. 110 // Used to handle the RFC date format = Sat, 13 Apr 2002 13:23:01 GMT 111 // 112 //private final DateFormat rfcDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z"); 113 114 /** 115 * Initializes the servlet from Engine properties. 116 */ 117 @Override 118 public void init( final ServletConfig config ) throws ServletException { 119 m_engine = Wiki.engine().find( config ); 120 final Properties props = m_engine.getWikiProperties(); 121 final String tmpDir = m_engine.getWorkDir() + File.separator + "attach-tmp"; 122 final String allowed = TextUtil.getStringProperty( props, AttachmentManager.PROP_ALLOWEDEXTENSIONS, null ); 123 m_maxSize = TextUtil.getIntegerProperty( props, AttachmentManager.PROP_MAXSIZE, Integer.MAX_VALUE ); 124 125 if( allowed != null && !allowed.isEmpty() ) { 126 m_allowedPatterns = allowed.toLowerCase().split( "\\s" ); 127 } else { 128 m_allowedPatterns = new String[ 0 ]; 129 } 130 131 final String forbidden = TextUtil.getStringProperty( props, AttachmentManager.PROP_FORBIDDENEXTENSIONS,null ); 132 if( forbidden != null && !forbidden.isEmpty() ) { 133 m_forbiddenPatterns = forbidden.toLowerCase().split("\\s"); 134 } else { 135 m_forbiddenPatterns = new String[0]; 136 } 137 138 final File f = new File( tmpDir ); 139 if( !f.exists() ) { 140 f.mkdirs(); 141 } else if( !f.isDirectory() ) { 142 log.fatal( "A file already exists where the temporary dir is supposed to be: " + tmpDir + ". Please remove it." ); 143 } 144 145 log.debug( "UploadServlet initialized. Using " + tmpDir + " for temporary storage." ); 146 } 147 148 private boolean isTypeAllowed( String name ) 149 { 150 if( name == null || name.isEmpty() ) return false; 151 152 name = name.toLowerCase(); 153 154 for( int i = 0; i < m_forbiddenPatterns.length; i++ ) 155 { 156 if( name.endsWith(m_forbiddenPatterns[i]) && !m_forbiddenPatterns[i].isEmpty() ) 157 return false; 158 } 159 160 for( int i = 0; i < m_allowedPatterns.length; i++ ) 161 { 162 if( name.endsWith(m_allowedPatterns[i]) && !m_allowedPatterns[i].isEmpty() ) 163 return true; 164 } 165 166 return m_allowedPatterns.length == 0; 167 } 168 169 /** 170 * Implements the OPTIONS method. 171 * 172 * @param req The servlet request 173 * @param res The servlet response 174 */ 175 176 @Override 177 protected void doOptions( final HttpServletRequest req, final HttpServletResponse res ) { 178 res.setHeader( "Allow", "GET, PUT, POST, OPTIONS, PROPFIND, PROPPATCH, MOVE, COPY, DELETE"); 179 res.setStatus( HttpServletResponse.SC_OK ); 180 } 181 182 /** 183 * Serves a GET with two parameters: 'wikiname' specifying the wikiname 184 * of the attachment, 'version' specifying the version indicator. 185 * 186 */ 187 // FIXME: Messages would need to be localized somehow. 188 @Override 189 public void doGet( final HttpServletRequest req, final HttpServletResponse res ) throws IOException { 190 final Context context = Wiki.context().create( m_engine, req, ContextEnum.PAGE_ATTACH.getRequestContext() ); 191 final AttachmentManager mgr = m_engine.getManager( AttachmentManager.class ); 192 final AuthorizationManager authmgr = m_engine.getManager( AuthorizationManager.class ); 193 final String version = req.getParameter( HDR_VERSION ); 194 final String nextPage = req.getParameter( "nextpage" ); 195 final String page = context.getPage().getName(); 196 int ver = WikiProvider.LATEST_VERSION; 197 198 if( page == null ) { 199 log.info( "Invalid attachment name." ); 200 res.sendError( HttpServletResponse.SC_BAD_REQUEST ); 201 return; 202 } 203 204 try( final OutputStream out = res.getOutputStream() ) { 205 log.debug("Attempting to download att "+page+", version "+version); 206 if( version != null ) { 207 ver = Integer.parseInt( version ); 208 } 209 210 final Attachment att = mgr.getAttachmentInfo( page, ver ); 211 if( att != null ) { 212 // 213 // Check if the user has permission for this attachment 214 // 215 216 final Permission permission = PermissionFactory.getPagePermission( att, "view" ); 217 if( !authmgr.checkPermission( context.getWikiSession(), permission ) ) { 218 log.debug("User does not have permission for this"); 219 res.sendError( HttpServletResponse.SC_FORBIDDEN ); 220 return; 221 } 222 223 // 224 // Check if the client already has a version of this attachment. 225 // 226 if( HttpUtil.checkFor304( req, att.getName(), att.getLastModified() ) ) { 227 log.debug( "Client has latest version already, sending 304..." ); 228 res.sendError( HttpServletResponse.SC_NOT_MODIFIED ); 229 return; 230 } 231 232 final String mimetype = getMimeType( context, att.getFileName() ); 233 res.setContentType( mimetype ); 234 235 // 236 // We use 'inline' instead of 'attachment' so that user agents 237 // can try to automatically open the file. 238 // 239 res.addHeader( "Content-Disposition", "inline; filename=\"" + att.getFileName() + "\";" ); 240 res.addDateHeader("Last-Modified",att.getLastModified().getTime()); 241 242 if( !att.isCacheable() ) { 243 res.addHeader( "Pragma", "no-cache" ); 244 res.addHeader( "Cache-control", "no-cache" ); 245 } 246 247 // If a size is provided by the provider, report it. 248 if( att.getSize() >= 0 ) { 249 // log.info("size:"+att.getSize()); 250 res.setContentLength( (int)att.getSize() ); 251 } 252 253 try( final InputStream in = mgr.getAttachmentStream( context, att ) ) { 254 int read; 255 final byte[] buffer = new byte[ BUFFER_SIZE ]; 256 257 while( ( read = in.read( buffer ) ) > -1 ) { 258 out.write( buffer, 0, read ); 259 } 260 } 261 262 if( log.isDebugEnabled() ) { 263 log.debug( "Attachment "+att.getFileName()+" sent to "+req.getRemoteUser()+" on "+HttpUtil.getRemoteAddress(req) ); 264 } 265 if( nextPage != null ) { 266 res.sendRedirect( 267 validateNextPage( 268 TextUtil.urlEncodeUTF8(nextPage), 269 m_engine.getURL( ContextEnum.WIKI_ERROR.getRequestContext(), "", null ) 270 ) 271 ); 272 } 273 274 } else { 275 final String msg = "Attachment '" + page + "', version " + ver + " does not exist."; 276 log.info( msg ); 277 res.sendError( HttpServletResponse.SC_NOT_FOUND, msg ); 278 } 279 } catch( final ProviderException pe ) { 280 log.debug("Provider failed while reading", pe); 281 // 282 // This might fail, if the response is already committed. So in that 283 // case we just log it. 284 // 285 sendError( res, "Provider error: "+ pe.getMessage() ); 286 } catch( final NumberFormatException nfe ) { 287 log.warn( "Invalid version number: " + version ); 288 res.sendError( HttpServletResponse.SC_BAD_REQUEST, "Invalid version number" ); 289 } catch( final SocketException se ) { 290 // 291 // These are very common in download situations due to aggressive 292 // clients. No need to try and send an error. 293 // 294 log.debug( "I/O exception during download", se ); 295 } catch( final IOException ioe ) { 296 // 297 // Client dropped the connection or something else happened. 298 // We don't know where the error came from, so we'll at least 299 // try to send an error and catch it quietly if it doesn't quite work. 300 // 301 log.debug( "I/O exception during download", ioe ); 302 sendError( res, "Error: " + ioe.getMessage() ); 303 } 304 } 305 306 void sendError( final HttpServletResponse res, final String message ) throws IOException { 307 try { 308 res.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message ); 309 } catch( final IllegalStateException e ) { 310 // ignore 311 } 312 } 313 314 /** 315 * Returns the mime type for this particular file. Case does not matter. 316 * 317 * @param ctx WikiContext; required to access the ServletContext of the request. 318 * @param fileName The name to check for. 319 * @return A valid mime type, or application/binary, if not recognized 320 */ 321 private static String getMimeType( final Context ctx, final String fileName ) { 322 String mimetype = null; 323 324 final HttpServletRequest req = ctx.getHttpRequest(); 325 if( req != null ) { 326 final ServletContext s = req.getSession().getServletContext(); 327 328 if( s != null ) { 329 mimetype = s.getMimeType( fileName.toLowerCase() ); 330 } 331 } 332 333 if( mimetype == null ) { 334 mimetype = "application/binary"; 335 } 336 337 return mimetype; 338 } 339 340 341 /** 342 * Grabs mime/multipart data and stores it into the temporary area. 343 * Uses other parameters to determine which name to store as. 344 * 345 * <p>The input to this servlet is generated by an HTML FORM with 346 * two parts. The first, named 'page', is the WikiName identifier 347 * for the parent file. The second, named 'content', is the binary 348 * content of the file. 349 * 350 */ 351 @Override 352 public void doPost( final HttpServletRequest req, final HttpServletResponse res ) throws IOException { 353 try { 354 final String nextPage = upload( req ); 355 req.getSession().removeAttribute("msg"); 356 res.sendRedirect( nextPage ); 357 } catch( final RedirectException e ) { 358 final Session session = Wiki.session().find( m_engine, req ); 359 session.addMessage( e.getMessage() ); 360 361 req.getSession().setAttribute("msg", e.getMessage()); 362 res.sendRedirect( e.getRedirect() ); 363 } 364 } 365 366 /** 367 * Validates the next page to be on the same server as this webapp. 368 * Fixes [JSPWIKI-46]. 369 */ 370 private String validateNextPage( String nextPage, final String errorPage ) { 371 if( nextPage.contains( "://" ) ) { 372 // It's an absolute link, so unless it starts with our address, we'll log an error. 373 if( !nextPage.startsWith( m_engine.getBaseURL() ) ) { 374 log.warn("Detected phishing attempt by redirecting to an unsecure location: "+nextPage); 375 nextPage = errorPage; 376 } 377 } 378 379 return nextPage; 380 } 381 382 /** 383 * Uploads a specific mime multipart input set, intercepts exceptions. 384 * 385 * @param req The servlet request 386 * @return The page to which we should go next. 387 * @throws RedirectException If there's an error and a redirection is needed 388 * @throws IOException If upload fails 389 */ 390 protected String upload( final HttpServletRequest req ) throws RedirectException, IOException { 391 final String msg; 392 final String attName = "(unknown)"; 393 final String errorPage = m_engine.getURL( ContextEnum.WIKI_ERROR.getRequestContext(), "", null ); // If something bad happened, Upload should be able to take care of most stuff 394 String nextPage = errorPage; 395 final String progressId = req.getParameter( "progressid" ); 396 397 // Check that we have a file upload request 398 if( !ServletFileUpload.isMultipartContent(req) ) { 399 throw new RedirectException( "Not a file upload", errorPage ); 400 } 401 402 try { 403 final FileItemFactory factory = new DiskFileItemFactory(); 404 405 // Create the context _before_ Multipart operations, otherwise strict servlet containers may fail when setting encoding. 406 final Context context = Wiki.context().create( m_engine, req, ContextEnum.PAGE_ATTACH.getRequestContext() ); 407 final UploadListener pl = new UploadListener(); 408 409 m_engine.getManager( ProgressManager.class ).startProgress( pl, progressId ); 410 411 final ServletFileUpload upload = new ServletFileUpload( factory ); 412 upload.setHeaderEncoding( StandardCharsets.UTF_8.name() ); 413 if( !context.hasAdminPermissions() ) { 414 upload.setFileSizeMax( m_maxSize ); 415 } 416 upload.setProgressListener( pl ); 417 final List<FileItem> items = upload.parseRequest( req ); 418 419 String wikipage = null; 420 String changeNote = null; 421 //FileItem actualFile = null; 422 final List<FileItem> fileItems = new ArrayList<>(); 423 424 for( final FileItem item : items ) { 425 if( item.isFormField() ) { 426 switch( item.getFieldName() ) { 427 case "page": 428 // FIXME: Kludge alert. We must end up with the parent page name, if this is an upload of a new revision 429 wikipage = item.getString( StandardCharsets.UTF_8.name() ); 430 final int x = wikipage.indexOf( "/" ); 431 if( x != -1 ) { 432 wikipage = wikipage.substring( 0, x ); 433 } 434 break; 435 case "changenote": 436 changeNote = item.getString( StandardCharsets.UTF_8.name() ); 437 if( changeNote != null ) { 438 changeNote = TextUtil.replaceEntities( changeNote ); 439 } 440 break; 441 case "nextpage": 442 nextPage = validateNextPage( item.getString( StandardCharsets.UTF_8.name() ), errorPage ); 443 break; 444 } 445 } else { 446 fileItems.add( item ); 447 } 448 } 449 450 if( fileItems.size() == 0 ) { 451 throw new RedirectException( "Broken file upload", errorPage ); 452 453 } else { 454 for( final FileItem actualFile : fileItems ) { 455 final String filename = actualFile.getName(); 456 final long fileSize = actualFile.getSize(); 457 try( final InputStream in = actualFile.getInputStream() ) { 458 executeUpload( context, in, filename, nextPage, wikipage, changeNote, fileSize ); 459 } 460 } 461 } 462 463 } catch( final ProviderException e ) { 464 msg = "Upload failed because the provider failed: "+e.getMessage(); 465 log.warn( msg + " (attachment: " + attName + ")", e ); 466 467 throw new IOException( msg ); 468 } catch( final IOException e ) { 469 // Show the submit page again, but with a bit more intimidating output. 470 msg = "Upload failure: " + e.getMessage(); 471 log.warn( msg + " (attachment: " + attName + ")", e ); 472 473 throw e; 474 } catch( final FileUploadException e ) { 475 // Show the submit page again, but with a bit more intimidating output. 476 msg = "Upload failure: " + e.getMessage(); 477 log.warn( msg + " (attachment: " + attName + ")", e ); 478 479 throw new IOException( msg, e ); 480 } finally { 481 m_engine.getManager( ProgressManager.class ).stopProgress( progressId ); 482 // FIXME: In case of exceptions should absolutely remove the uploaded file. 483 } 484 485 return nextPage; 486 } 487 488 /** 489 * 490 * @param context the wiki context 491 * @param data the input stream data 492 * @param filename the name of the file to upload 493 * @param errorPage the place to which you want to get a redirection 494 * @param parentPage the page to which the file should be attached 495 * @param changenote The change note 496 * @param contentLength The content length 497 * @return <code>true</code> if upload results in the creation of a new page; 498 * <code>false</code> otherwise 499 * @throws RedirectException If the content needs to be redirected 500 * @throws IOException If there is a problem in the upload. 501 * @throws ProviderException If there is a problem in the backend. 502 */ 503 protected boolean executeUpload( final Context context, final InputStream data, 504 String filename, final String errorPage, 505 final String parentPage, final String changenote, 506 final long contentLength ) 507 throws RedirectException, IOException, ProviderException { 508 boolean created = false; 509 510 try { 511 filename = AttachmentManager.validateFileName( filename ); 512 } catch( final WikiException e ) { 513 // this is a kludge, the exception that is caught here contains the i18n key 514 // here we have the context available, so we can internationalize it properly : 515 throw new RedirectException (Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE ) 516 .getString( e.getMessage() ), errorPage ); 517 } 518 519 // 520 // FIXME: This has the unfortunate side effect that it will receive the 521 // contents. But we can't figure out the page to redirect to 522 // before we receive the file, due to the stupid constructor of MultipartRequest. 523 // 524 525 if( !context.hasAdminPermissions() ) { 526 if( contentLength > m_maxSize ) { 527 // FIXME: Does not delete the received files. 528 throw new RedirectException( "File exceeds maximum size ("+m_maxSize+" bytes)", errorPage ); 529 } 530 531 if( !isTypeAllowed(filename) ) { 532 throw new RedirectException( "Files of this type may not be uploaded to this wiki", errorPage ); 533 } 534 } 535 536 final Principal user = context.getCurrentUser(); 537 final AttachmentManager mgr = m_engine.getManager( AttachmentManager.class ); 538 539 log.debug("file="+filename); 540 541 if( data == null ) { 542 log.error("File could not be opened."); 543 throw new RedirectException("File could not be opened.", errorPage); 544 } 545 546 // Check whether we already have this kind of a page. If the "page" parameter already defines an attachment 547 // name for an update, then we just use that file. Otherwise we create a new attachment, and use the 548 // filename given. Incidentally, this will also mean that if the user uploads a file with the exact 549 // same name than some other previous attachment, then that attachment gains a new version. 550 Attachment att = mgr.getAttachmentInfo( context.getPage().getName() ); 551 if( att == null ) { 552 att = new org.apache.wiki.attachment.Attachment( m_engine, parentPage, filename ); 553 created = true; 554 } 555 att.setSize( contentLength ); 556 557 // Check if we're allowed to do this? 558 final Permission permission = PermissionFactory.getPagePermission( att, "upload" ); 559 if( m_engine.getManager( AuthorizationManager.class ).checkPermission( context.getWikiSession(), permission ) ) { 560 if( user != null ) { 561 att.setAuthor( user.getName() ); 562 } 563 564 if( changenote != null && !changenote.isEmpty() ) { 565 att.setAttribute( Page.CHANGENOTE, changenote ); 566 } 567 568 try { 569 m_engine.getManager( AttachmentManager.class ).storeAttachment( att, data ); 570 } catch( final ProviderException pe ) { 571 // this is a kludge, the exception that is caught here contains the i18n key 572 // here we have the context available, so we can internationalize it properly : 573 throw new ProviderException( Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE ).getString( pe.getMessage() ) ); 574 } 575 576 log.info( "User " + user + " uploaded attachment to " + parentPage + " called "+filename+", size " + att.getSize() ); 577 } else { 578 throw new RedirectException( "No permission to upload a file", errorPage ); 579 } 580 581 return created; 582 } 583 584 /** 585 * Provides tracking for upload progress. 586 * 587 */ 588 private static class UploadListener extends ProgressItem implements ProgressListener { 589 public long m_currentBytes; 590 public long m_totalBytes; 591 592 @Override 593 public void update( final long recvdBytes, final long totalBytes, final int item) { 594 m_currentBytes = recvdBytes; 595 m_totalBytes = totalBytes; 596 } 597 598 @Override 599 public int getProgress() { 600 return ( int )( ( ( float )m_currentBytes / m_totalBytes ) * 100 + 0.5 ); 601 } 602 } 603 604} 605 606