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