    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