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 // 222 // We use 'inline' instead of 'attachment' so that user agents 223 // can try to automatically open the file. 224 // 225 res.addHeader( "Content-Disposition", "inline; filename=\"" + att.getFileName() + "\";" ); 226 res.addDateHeader("Last-Modified",att.getLastModified().getTime()); 227 228 if( !att.isCacheable() ) { 229 res.addHeader( "Pragma", "no-cache" ); 230 res.addHeader( "Cache-control", "no-cache" ); 231 } 232 233 // If a size is provided by the provider, report it. 234 if( att.getSize() >= 0 ) { 235 // log.info("size:"+att.getSize()); 236 res.setContentLength( (int)att.getSize() ); 237 } 238 239 try( final InputStream in = mgr.getAttachmentStream( context, att ) ) { 240 int read; 241 final byte[] buffer = new byte[ BUFFER_SIZE ]; 242 243 while( ( read = in.read( buffer ) ) > -1 ) { 244 out.write( buffer, 0, read ); 245 } 246 } 247 log.debug( "Attachment {} sent to {} on {}", att.getFileName(), req.getRemoteUser(), HttpUtil.getRemoteAddress(req) ); 248 if( nextPage != null ) { 249 res.sendRedirect( 250 validateNextPage( 251 TextUtil.urlEncodeUTF8(nextPage), 252 m_engine.getURL( ContextEnum.WIKI_ERROR.getRequestContext(), "", null ) 253 ) 254 ); 255 } 256 257 } else { 258 final String msg = "Attachment '" + page + "', version " + ver + " does not exist."; 259 log.info( msg ); 260 res.sendError( HttpServletResponse.SC_NOT_FOUND, msg ); 261 } 262 } catch( final ProviderException pe ) { 263 log.debug("Provider failed while reading", pe); 264 // 265 // This might fail, if the response is already committed. So in that 266 // case we just log it. 267 // 268 sendError( res, "Provider error: "+ pe.getMessage() ); 269 } catch( final NumberFormatException nfe ) { 270 log.warn( "Invalid version number: " + version ); 271 res.sendError( HttpServletResponse.SC_BAD_REQUEST, "Invalid version number" ); 272 } catch( final SocketException se ) { 273 // 274 // These are very common in download situations due to aggressive 275 // clients. No need to try and send an error. 276 // 277 log.debug( "I/O exception during download", se ); 278 } catch( final IOException ioe ) { 279 // 280 // Client dropped the connection or something else happened. 281 // We don't know where the error came from, so we'll at least 282 // try to send an error and catch it quietly if it doesn't quite work. 283 // 284 log.debug( "I/O exception during download", ioe ); 285 sendError( res, "Error: " + ioe.getMessage() ); 286 } 287 } 288 289 void sendError( final HttpServletResponse res, final String message ) throws IOException { 290 try { 291 res.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message ); 292 } catch( final IllegalStateException e ) { 293 // ignore 294 } 295 } 296 297 /** 298 * Returns the mime type for this particular file. Case does not matter. 299 * 300 * @param ctx WikiContext; required to access the ServletContext of the request. 301 * @param fileName The name to check for. 302 * @return A valid mime type, or application/binary, if not recognized 303 */ 304 private static String getMimeType( final Context ctx, final String fileName ) { 305 String mimetype = null; 306 307 final HttpServletRequest req = ctx.getHttpRequest(); 308 if( req != null ) { 309 final ServletContext s = req.getSession().getServletContext(); 310 311 if( s != null ) { 312 mimetype = s.getMimeType( fileName.toLowerCase() ); 313 } 314 } 315 316 if( mimetype == null ) { 317 mimetype = "application/binary"; 318 } 319 320 return mimetype; 321 } 322 323 324 /** 325 * Grabs mime/multipart data and stores it into the temporary area. 326 * Uses other parameters to determine which name to store as. 327 * 328 * <p>The input to this servlet is generated by an HTML FORM with 329 * two parts. The first, named 'page', is the WikiName identifier 330 * for the parent file. The second, named 'content', is the binary 331 * content of the file. 332 * 333 */ 334 @Override 335 public void doPost( final HttpServletRequest req, final HttpServletResponse res ) throws IOException { 336 try { 337 final String nextPage = upload( req ); 338 req.getSession().removeAttribute("msg"); 339 res.sendRedirect( nextPage ); 340 } catch( final RedirectException e ) { 341 final Session session = Wiki.session().find( m_engine, req ); 342 session.addMessage( e.getMessage() ); 343 344 req.getSession().setAttribute("msg", e.getMessage()); 345 res.sendRedirect( e.getRedirect() ); 346 } 347 } 348 349 /** 350 * Validates the next page to be on the same server as this webapp. 351 * Fixes [JSPWIKI-46]. 352 */ 353 private String validateNextPage( String nextPage, final String errorPage ) { 354 if( nextPage.contains( "://" ) ) { 355 // It's an absolute link, so unless it starts with our address, we'll log an error. 356 if( !nextPage.startsWith( m_engine.getBaseURL() ) ) { 357 log.warn("Detected phishing attempt by redirecting to an unsecure location: "+nextPage); 358 nextPage = errorPage; 359 } 360 } 361 362 return nextPage; 363 } 364 365 /** 366 * Uploads a specific mime multipart input set, intercepts exceptions. 367 * 368 * @param req The servlet request 369 * @return The page to which we should go next. 370 * @throws RedirectException If there's an error and a redirection is needed 371 * @throws IOException If upload fails 372 */ 373 protected String upload( final HttpServletRequest req ) throws RedirectException, IOException { 374 final String msg; 375 final String attName = "(unknown)"; 376 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 377 String nextPage = errorPage; 378 final String progressId = req.getParameter( "progressid" ); 379 380 // Check that we have a file upload request 381 if( !ServletFileUpload.isMultipartContent(req) ) { 382 throw new RedirectException( "Not a file upload", errorPage ); 383 } 384 385 try { 386 final FileItemFactory factory = new DiskFileItemFactory(); 387 388 // Create the context _before_ Multipart operations, otherwise strict servlet containers may fail when setting encoding. 389 final Context context = Wiki.context().create( m_engine, req, ContextEnum.PAGE_ATTACH.getRequestContext() ); 390 final UploadListener pl = new UploadListener(); 391 392 m_engine.getManager( ProgressManager.class ).startProgress( pl, progressId ); 393 394 final ServletFileUpload upload = new ServletFileUpload( factory ); 395 upload.setHeaderEncoding( StandardCharsets.UTF_8.name() ); 396 if( !context.hasAdminPermissions() ) { 397 upload.setFileSizeMax( m_maxSize ); 398 } 399 upload.setProgressListener( pl ); 400 final List<FileItem> items = upload.parseRequest( req ); 401 402 String wikipage = null; 403 String changeNote = null; 404 //FileItem actualFile = null; 405 final List<FileItem> fileItems = new ArrayList<>(); 406 407 for( final FileItem item : items ) { 408 if( item.isFormField() ) { 409 switch( item.getFieldName() ) { 410 case "page": 411 // FIXME: Kludge alert. We must end up with the parent page name, if this is an upload of a new revision 412 wikipage = item.getString( StandardCharsets.UTF_8.name() ); 413 final int x = wikipage.indexOf( "/" ); 414 if( x != -1 ) { 415 wikipage = wikipage.substring( 0, x ); 416 } 417 break; 418 case "changenote": 419 changeNote = item.getString( StandardCharsets.UTF_8.name() ); 420 if( changeNote != null ) { 421 changeNote = TextUtil.replaceEntities( changeNote ); 422 } 423 break; 424 case "nextpage": 425 nextPage = validateNextPage( item.getString( StandardCharsets.UTF_8.name() ), errorPage ); 426 break; 427 } 428 } else { 429 fileItems.add( item ); 430 } 431 } 432 433 if( fileItems.size() == 0 ) { 434 throw new RedirectException( "Broken file upload", errorPage ); 435 436 } else { 437 for( final FileItem actualFile : fileItems ) { 438 final String filename = actualFile.getName(); 439 final long fileSize = actualFile.getSize(); 440 try( final InputStream in = actualFile.getInputStream() ) { 441 executeUpload( context, in, filename, nextPage, wikipage, changeNote, fileSize ); 442 } 443 } 444 } 445 446 } catch( final ProviderException e ) { 447 msg = "Upload failed because the provider failed: "+e.getMessage(); 448 log.warn( msg + " (attachment: " + attName + ")", e ); 449 450 throw new IOException( msg ); 451 } catch( final IOException e ) { 452 // Show the submit page again, but with a bit more intimidating output. 453 msg = "Upload failure: " + e.getMessage(); 454 log.warn( msg + " (attachment: " + attName + ")", e ); 455 456 throw e; 457 } catch( final FileUploadException e ) { 458 // Show the submit page again, but with a bit more intimidating output. 459 msg = "Upload failure: " + e.getMessage(); 460 log.warn( msg + " (attachment: " + attName + ")", e ); 461 462 throw new IOException( msg, e ); 463 } finally { 464 m_engine.getManager( ProgressManager.class ).stopProgress( progressId ); 465 // FIXME: In case of exceptions should absolutely remove the uploaded file. 466 } 467 468 return nextPage; 469 } 470 471 /** 472 * 473 * @param context the wiki context 474 * @param data the input stream data 475 * @param filename the name of the file to upload 476 * @param errorPage the place to which you want to get a redirection 477 * @param parentPage the page to which the file should be attached 478 * @param changenote The change note 479 * @param contentLength The content length 480 * @return <code>true</code> if upload results in the creation of a new page; 481 * <code>false</code> otherwise 482 * @throws RedirectException If the content needs to be redirected 483 * @throws IOException If there is a problem in the upload. 484 * @throws ProviderException If there is a problem in the backend. 485 */ 486 protected boolean executeUpload( final Context context, final InputStream data, 487 String filename, final String errorPage, 488 final String parentPage, final String changenote, 489 final long contentLength ) 490 throws RedirectException, IOException, ProviderException { 491 boolean created = false; 492 493 try { 494 filename = AttachmentManager.validateFileName( filename ); 495 } catch( final WikiException e ) { 496 // this is a kludge, the exception that is caught here contains the i18n key 497 // here we have the context available, so we can internationalize it properly : 498 throw new RedirectException (Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE ) 499 .getString( e.getMessage() ), errorPage ); 500 } 501 502 // 503 // FIXME: This has the unfortunate side effect that it will receive the 504 // contents. But we can't figure out the page to redirect to 505 // before we receive the file, due to the stupid constructor of MultipartRequest. 506 // 507 508 if( !context.hasAdminPermissions() ) { 509 if( contentLength > m_maxSize ) { 510 // FIXME: Does not delete the received files. 511 throw new RedirectException( "File exceeds maximum size ("+m_maxSize+" bytes)", errorPage ); 512 } 513 514 if( !isTypeAllowed(filename) ) { 515 throw new RedirectException( "Files of this type may not be uploaded to this wiki", errorPage ); 516 } 517 } 518 519 final Principal user = context.getCurrentUser(); 520 final AttachmentManager mgr = m_engine.getManager( AttachmentManager.class ); 521 522 log.debug("file="+filename); 523 524 if( data == null ) { 525 log.error("File could not be opened."); 526 throw new RedirectException("File could not be opened.", errorPage); 527 } 528 529 // Check whether we already have this kind of page. If the "page" parameter already defines an attachment 530 // name for an update, then we just use that file. Otherwise, we create a new attachment, and use the 531 // filename given. Incidentally, this will also mean that if the user uploads a file with the exact 532 // same name than some other previous attachment, then that attachment gains a new version. 533 Attachment att = mgr.getAttachmentInfo( context.getPage().getName() ); 534 if( att == null ) { 535 att = new org.apache.wiki.attachment.Attachment( m_engine, parentPage, filename ); 536 created = true; 537 } 538 att.setSize( contentLength ); 539 540 // Check if we're allowed to do this? 541 final Permission permission = PermissionFactory.getPagePermission( att, "upload" ); 542 if( m_engine.getManager( AuthorizationManager.class ).checkPermission( context.getWikiSession(), permission ) ) { 543 if( user != null ) { 544 att.setAuthor( user.getName() ); 545 } 546 547 if( changenote != null && !changenote.isEmpty() ) { 548 att.setAttribute( Page.CHANGENOTE, changenote ); 549 } 550 551 try { 552 m_engine.getManager( AttachmentManager.class ).storeAttachment( att, data ); 553 } catch( final ProviderException pe ) { 554 // this is a kludge, the exception that is caught here contains the i18n key 555 // here we have the context available, so we can internationalize it properly : 556 throw new ProviderException( Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE ).getString( pe.getMessage() ) ); 557 } 558 559 log.info( "User " + user + " uploaded attachment to " + parentPage + " called "+filename+", size " + att.getSize() ); 560 } else { 561 throw new RedirectException( "No permission to upload a file", errorPage ); 562 } 563 564 return created; 565 } 566 567 /** 568 * Provides tracking for upload progress. 569 * 570 */ 571 private static class UploadListener extends ProgressItem implements ProgressListener { 572 public long m_currentBytes; 573 public long m_totalBytes; 574 575 @Override 576 public void update( final long recvdBytes, final long totalBytes, final int item) { 577 m_currentBytes = recvdBytes; 578 m_totalBytes = totalBytes; 579 } 580 581 @Override 582 public int getProgress() { 583 return ( int )( ( ( float )m_currentBytes / m_totalBytes ) * 100 + 0.5 ); 584 } 585 } 586 587} 588 589