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 java.io.File;
022import java.io.IOException;
023import java.io.InputStream;
024import java.io.OutputStream;
025import java.net.SocketException;
026import java.security.Permission;
027import java.security.Principal;
028import java.util.List;
029import java.util.Properties;
030
031import javax.servlet.ServletConfig;
032import javax.servlet.ServletContext;
033import javax.servlet.ServletException;
034import javax.servlet.http.HttpServlet;
035import javax.servlet.http.HttpServletRequest;
036import javax.servlet.http.HttpServletResponse;
037
038import org.apache.commons.fileupload.FileItem;
039import org.apache.commons.fileupload.FileItemFactory;
040import org.apache.commons.fileupload.FileUploadException;
041import org.apache.commons.fileupload.ProgressListener;
042import org.apache.commons.fileupload.disk.DiskFileItemFactory;
043import org.apache.commons.fileupload.servlet.ServletFileUpload;
044import org.apache.commons.io.IOUtils;
045import org.apache.log4j.Logger;
046import org.apache.wiki.WikiContext;
047import org.apache.wiki.WikiEngine;
048import org.apache.wiki.WikiPage;
049import org.apache.wiki.WikiProvider;
050import org.apache.wiki.WikiSession;
051import org.apache.wiki.api.exceptions.ProviderException;
052import org.apache.wiki.api.exceptions.RedirectException;
053import org.apache.wiki.api.exceptions.WikiException;
054import org.apache.wiki.auth.AuthorizationManager;
055import org.apache.wiki.auth.permissions.PermissionFactory;
056import org.apache.wiki.i18n.InternationalizationManager;
057import org.apache.wiki.preferences.Preferences;
058import org.apache.wiki.ui.progress.ProgressItem;
059import org.apache.wiki.util.HttpUtil;
060import 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 */
075public 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_FORBIDDENEXTENSIONS,
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            List<FileItem> fileItems = new java.util.ArrayList<FileItem>();
499
500            for( FileItem item : items )
501            {
502                if( item.isFormField() )
503                {
504                    if( item.getFieldName().equals("page") )
505                    {
506                        //
507                        // FIXME: Kludge alert.  We must end up with the parent page name,
508                        //        if this is an upload of a new revision
509                        //
510
511                        wikipage = item.getString("UTF-8");
512                        int x = wikipage.indexOf("/");
513
514                        if( x != -1 ) wikipage = wikipage.substring(0,x);
515                    }
516                    else if( item.getFieldName().equals("changenote") )
517                    {
518                        changeNote = item.getString("UTF-8");
519                        if (changeNote != null)
520                        {
521                            changeNote = TextUtil.replaceEntities(changeNote);
522                        }
523                    }
524                    else if( item.getFieldName().equals( "nextpage" ) )
525                    {
526                        nextPage = validateNextPage( item.getString("UTF-8"), errorPage );
527                    }
528                }
529                else
530                {
531                    fileItems.add( item );
532                }
533            }
534
535            if( fileItems.size() == 0 )
536            {
537                throw new RedirectException( "Broken file upload", errorPage );
538
539            } else {
540
541                for( FileItem actualFile : fileItems ){
542
543                    String filename = actualFile.getName();
544                    long   fileSize = actualFile.getSize();
545                    InputStream in  = actualFile.getInputStream();
546
547                    try
548                    {
549                        executeUpload( context, in, filename, nextPage, wikipage, changeNote, fileSize );
550                    }
551                    finally
552                    {
553                        IOUtils.closeQuietly( in );
554                    }
555
556                }
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