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