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