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