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