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