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