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