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                //
222                //  We use 'inline' instead of 'attachment' so that user agents
223                //  can try to automatically open the file.
224                //
225                res.addHeader( "Content-Disposition", "inline; filename=\"" + att.getFileName() + "\";" );
226                res.addDateHeader("Last-Modified",att.getLastModified().getTime());
227
228                if( !att.isCacheable() ) {
229                    res.addHeader( "Pragma", "no-cache" );
230                    res.addHeader( "Cache-control", "no-cache" );
231                }
232
233                // If a size is provided by the provider, report it.
234                if( att.getSize() >= 0 ) {
235                    // LOG.info("size:"+att.getSize());
236                    res.setContentLength( (int)att.getSize() );
237                }
238
239                try( final InputStream  in = mgr.getAttachmentStream( context, att ) ) {
240                    int read;
241                    final byte[] buffer = new byte[ BUFFER_SIZE ];
242
243                    while( ( read = in.read( buffer ) ) > -1 ) {
244                        out.write( buffer, 0, read );
245                    }
246                }
247                LOG.debug( "Attachment {} sent to {} on {}", att.getFileName(), req.getRemoteUser(), HttpUtil.getRemoteAddress(req) );
248                if( nextPage != null ) {
249                    res.sendRedirect(
250                        validateNextPage(
251                            TextUtil.urlEncodeUTF8(nextPage),
252                            m_engine.getURL( ContextEnum.WIKI_ERROR.getRequestContext(), "", null )
253                        )
254                    );
255                }
256
257            } else {
258                final String msg = "Attachment '" + page + "', version " + ver + " does not exist.";
259                LOG.info( msg );
260                res.sendError( HttpServletResponse.SC_NOT_FOUND, msg );
261            }
262        } catch( final ProviderException pe ) {
263            LOG.debug("Provider failed while reading", pe);
264            //
265            //  This might fail, if the response is already committed.  So in that
266            //  case we just log it.
267            //
268            sendError( res, "Provider error: "+ pe.getMessage() );
269        } catch( final NumberFormatException nfe ) {
270            LOG.warn( "Invalid version number: " + version );
271            res.sendError( HttpServletResponse.SC_BAD_REQUEST, "Invalid version number" );
272        } catch( final SocketException se ) {
273            //
274            //  These are very common in download situations due to aggressive
275            //  clients.  No need to try and send an error.
276            //
277            LOG.debug( "I/O exception during download", se );
278        } catch( final IOException ioe ) {
279            //
280            //  Client dropped the connection or something else happened.
281            //  We don't know where the error came from, so we'll at least
282            //  try to send an error and catch it quietly if it doesn't quite work.
283            //
284            LOG.debug( "I/O exception during download", ioe );
285            sendError( res, "Error: " + ioe.getMessage() );
286        }
287    }
288
289    void sendError( final HttpServletResponse res, final String message ) throws IOException {
290        try {
291            res.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message );
292        } catch( final IllegalStateException e ) {
293            // ignore
294        }
295    }
296
297    /**
298     *  Returns the mime type for this particular file.  Case does not matter.
299     *
300     * @param ctx WikiContext; required to access the ServletContext of the request.
301     * @param fileName The name to check for.
302     * @return A valid mime type, or application/binary, if not recognized
303     */
304    private static String getMimeType( final Context ctx, final String fileName ) {
305        String mimetype = null;
306
307        final HttpServletRequest req = ctx.getHttpRequest();
308        if( req != null ) {
309            final ServletContext s = req.getSession().getServletContext();
310
311            if( s != null ) {
312                mimetype = s.getMimeType( fileName.toLowerCase() );
313            }
314        }
315
316        if( mimetype == null ) {
317            mimetype = "application/binary";
318        }
319
320        return mimetype;
321    }
322
323
324    /**
325     * Grabs mime/multipart data and stores it into the temporary area.
326     * Uses other parameters to determine which name to store as.
327     *
328     * <p>The input to this servlet is generated by an HTML FORM with
329     * two parts. The first, named 'page', is the WikiName identifier
330     * for the parent file. The second, named 'content', is the binary
331     * content of the file.
332     *
333     */
334    @Override
335    public void doPost( final HttpServletRequest req, final HttpServletResponse res ) throws IOException {
336        try {
337            final String nextPage = upload( req );
338            req.getSession().removeAttribute("msg");
339            res.sendRedirect( nextPage );
340        } catch( final RedirectException e ) {
341            final Session session = Wiki.session().find( m_engine, req );
342            session.addMessage( e.getMessage() );
343
344            req.getSession().setAttribute("msg", e.getMessage());
345            res.sendRedirect( e.getRedirect() );
346        }
347    }
348
349    /**
350     *  Validates the next page to be on the same server as this webapp.
351     *  Fixes [JSPWIKI-46].
352     */
353    private String validateNextPage( String nextPage, final String errorPage ) {
354        if( nextPage.contains( "://" ) ) {
355            // It's an absolute link, so unless it starts with our address, we'll log an error.
356            if( !nextPage.startsWith( m_engine.getBaseURL() ) ) {
357                LOG.warn("Detected phishing attempt by redirecting to an unsecure location: "+nextPage);
358                nextPage = errorPage;
359            }
360        }
361
362        return nextPage;
363    }
364
365    /**
366     *  Uploads a specific mime multipart input set, intercepts exceptions.
367     *
368     *  @param req The servlet request
369     *  @return The page to which we should go next.
370     *  @throws RedirectException If there's an error and a redirection is needed
371     *  @throws IOException If upload fails
372     */
373    protected String upload( final HttpServletRequest req ) throws RedirectException, IOException {
374        final String msg;
375        final String attName = "(unknown)";
376        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
377        String nextPage = errorPage;
378        final String progressId = req.getParameter( "progressid" );
379
380        // Check that we have a file upload request
381        if( !ServletFileUpload.isMultipartContent(req) ) {
382            throw new RedirectException( "Not a file upload", errorPage );
383        }
384
385        try {
386            final FileItemFactory factory = new DiskFileItemFactory();
387
388            // Create the context _before_ Multipart operations, otherwise strict servlet containers may fail when setting encoding.
389            final Context context = Wiki.context().create( m_engine, req, ContextEnum.PAGE_ATTACH.getRequestContext() );
390            final UploadListener pl = new UploadListener();
391
392            m_engine.getManager( ProgressManager.class ).startProgress( pl, progressId );
393
394            final ServletFileUpload upload = new ServletFileUpload( factory );
395            upload.setHeaderEncoding( StandardCharsets.UTF_8.name() );
396            if( !context.hasAdminPermissions() ) {
397                upload.setFileSizeMax( m_maxSize );
398            }
399            upload.setProgressListener( pl );
400            final List<FileItem> items = upload.parseRequest( req );
401
402            String   wikipage   = null;
403            String   changeNote = null;
404            //FileItem actualFile = null;
405            final List<FileItem> fileItems = new ArrayList<>();
406
407            for( final FileItem item : items ) {
408                if( item.isFormField() ) {
409                    switch( item.getFieldName() ) {
410                    case "page":
411                        // FIXME: Kludge alert.  We must end up with the parent page name, if this is an upload of a new revision
412                        wikipage = item.getString( StandardCharsets.UTF_8.name() );
413                        final int x = wikipage.indexOf( "/" );
414                        if( x != -1 ) {
415                            wikipage = wikipage.substring( 0, x );
416                        }
417                        break;
418                    case "changenote":
419                        changeNote = item.getString( StandardCharsets.UTF_8.name() );
420                        if( changeNote != null ) {
421                            changeNote = TextUtil.replaceEntities( changeNote );
422                        }
423                        break;
424                    case "nextpage":
425                        nextPage = validateNextPage( item.getString( StandardCharsets.UTF_8.name() ), errorPage );
426                        break;
427                    }
428                } else {
429                    fileItems.add( item );
430                }
431            }
432
433            if( fileItems.size() == 0 ) {
434                throw new RedirectException( "Broken file upload", errorPage );
435
436            } else {
437                for( final FileItem actualFile : fileItems ) {
438                    final String filename = actualFile.getName();
439                    final long   fileSize = actualFile.getSize();
440                    try( final InputStream in  = actualFile.getInputStream() ) {
441                        executeUpload( context, in, filename, nextPage, wikipage, changeNote, fileSize );
442                    }
443                }
444            }
445
446        } catch( final ProviderException e ) {
447            msg = "Upload failed because the provider failed: "+e.getMessage();
448            LOG.warn( msg + " (attachment: " + attName + ")", e );
449
450            throw new IOException( msg );
451        } catch( final IOException e ) {
452            // Show the submit page again, but with a bit more intimidating output.
453            msg = "Upload failure: " + e.getMessage();
454            LOG.warn( msg + " (attachment: " + attName + ")", e );
455
456            throw e;
457        } catch( final FileUploadException e ) {
458            // Show the submit page again, but with a bit more intimidating output.
459            msg = "Upload failure: " + e.getMessage();
460            LOG.warn( msg + " (attachment: " + attName + ")", e );
461
462            throw new IOException( msg, e );
463        } finally {
464            m_engine.getManager( ProgressManager.class ).stopProgress( progressId );
465            // FIXME: In case of exceptions should absolutely remove the uploaded file.
466        }
467
468        return nextPage;
469    }
470
471    /**
472     *
473     * @param context the wiki context
474     * @param data the input stream data
475     * @param filename the name of the file to upload
476     * @param errorPage the place to which you want to get a redirection
477     * @param parentPage the page to which the file should be attached
478     * @param changenote The change note
479     * @param contentLength The content length
480     * @return <code>true</code> if upload results in the creation of a new page;
481     * <code>false</code> otherwise
482     * @throws RedirectException If the content needs to be redirected
483     * @throws IOException       If there is a problem in the upload.
484     * @throws ProviderException If there is a problem in the backend.
485     */
486    protected boolean executeUpload( final Context context, final InputStream data,
487                                     String filename, final String errorPage,
488                                     final String parentPage, final String changenote,
489                                     final long contentLength )
490            throws RedirectException, IOException, ProviderException {
491        boolean created = false;
492
493        try {
494            filename = AttachmentManager.validateFileName( filename );
495        } catch( final WikiException e ) {
496            // this is a kludge, the exception that is caught here contains the i18n key
497            // here we have the context available, so we can internationalize it properly :
498            throw new RedirectException (Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE )
499                    .getString( e.getMessage() ), errorPage );
500        }
501
502        //
503        //  FIXME: This has the unfortunate side effect that it will receive the
504        //  contents.  But we can't figure out the page to redirect to
505        //  before we receive the file, due to the stupid constructor of MultipartRequest.
506        //
507
508        if( !context.hasAdminPermissions() ) {
509            if( contentLength > m_maxSize ) {
510                // FIXME: Does not delete the received files.
511                throw new RedirectException( "File exceeds maximum size ("+m_maxSize+" bytes)", errorPage );
512            }
513
514            if( !isTypeAllowed(filename) ) {
515                throw new RedirectException( "Files of this type may not be uploaded to this wiki", errorPage );
516            }
517        }
518
519        final Principal user    = context.getCurrentUser();
520        final AttachmentManager mgr = m_engine.getManager( AttachmentManager.class );
521
522        LOG.debug("file="+filename);
523
524        if( data == null ) {
525            LOG.error("File could not be opened.");
526            throw new RedirectException("File could not be opened.", errorPage);
527        }
528
529        //  Check whether we already have this kind of page. If the "page" parameter already defines an attachment
530        //  name for an update, then we just use that file. Otherwise, we create a new attachment, and use the
531        //  filename given.  Incidentally, this will also mean that if the user uploads a file with the exact
532        //  same name than some other previous attachment, then that attachment gains a new version.
533        Attachment att = mgr.getAttachmentInfo( context.getPage().getName() );
534        if( att == null ) {
535            att = new org.apache.wiki.attachment.Attachment( m_engine, parentPage, filename );
536            created = true;
537        }
538        att.setSize( contentLength );
539
540        //  Check if we're allowed to do this?
541        final Permission permission = PermissionFactory.getPagePermission( att, "upload" );
542        if( m_engine.getManager( AuthorizationManager.class ).checkPermission( context.getWikiSession(), permission ) ) {
543            if( user != null ) {
544                att.setAuthor( user.getName() );
545            }
546
547            if( changenote != null && !changenote.isEmpty() ) {
548                att.setAttribute( Page.CHANGENOTE, changenote );
549            }
550
551            try {
552                m_engine.getManager( AttachmentManager.class ).storeAttachment( att, data );
553            } catch( final ProviderException pe ) {
554                // this is a kludge, the exception that is caught here contains the i18n key
555                // here we have the context available, so we can internationalize it properly :
556                throw new ProviderException( Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE ).getString( pe.getMessage() ) );
557            }
558
559            LOG.info( "User " + user + " uploaded attachment to " + parentPage + " called "+filename+", size " + att.getSize() );
560        } else {
561            throw new RedirectException( "No permission to upload a file", errorPage );
562        }
563
564        return created;
565    }
566
567    /**
568     *  Provides tracking for upload progress.
569     *
570     */
571    private static class UploadListener extends ProgressItem implements ProgressListener {
572        public long m_currentBytes;
573        public long m_totalBytes;
574
575        @Override
576        public void update( final long recvdBytes, final long totalBytes, final int item) {
577            m_currentBytes = recvdBytes;
578            m_totalBytes   = totalBytes;
579        }
580
581        @Override
582        public int getProgress() {
583            return ( int )( ( ( float )m_currentBytes / m_totalBytes ) * 100 + 0.5 );
584        }
585    }
586
587}
588
589