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