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