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