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