    001/*
    002 * Copyright (c) 2009 The openGion Project.
    003 *
    004 * Licensed under the Apache License, Version 2.0 (the "License");
    005 * you may not use this file except in compliance with the License.
    006 * You may obtain a copy of the License at
    007 *
    008 *     http://www.apache.org/licenses/LICENSE-2.0
    009 *
    010 * Unless required by applicable law or agreed to in writing, software
    011 * distributed under the License is distributed on an "AS IS" BASIS,
    012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    013 * either express or implied. See the License for the specific language
    014 * governing permissions and limitations under the License.
    015 */
    016package org.opengion.hayabusa.servlet.multipart;
    017
    018import java.io.IOException;
    019import java.util.List;
    020import java.util.ArrayList;
    021import java.util.Locale ;
    022
    023import jakarta.servlet.http.HttpServletRequest;
    024import jakarta.servlet.ServletInputStream;
    025
    026import org.opengion.fukurou.util.StringUtil;                                            // 6.9.0.0 (2018/01/31)
    027import org.opengion.fukurou.system.Closer ;
    028
    029import static org.opengion.fukurou.system.HybsConst.CR ;                        // 6.9.0.0 (2018/01/31)
    030import static org.opengion.fukurou.system.HybsConst.BUFFER_MIDDLE;      // 6.1.0.0 (2014/12/26) refactoring
    031
    032/**
    033 * ファイルアップロード時のマルチパート処理のパーサーです。
    034 *
    035 * @og.group その他機能
    036 *
    037 * @version  4.0
    038 * @author   Kazuhiko Hasegawa
    039 * @since    JDK5.0,
    040 */
    041public class MultipartParser {
    042        private final ServletInputStream in;
    043        private final String boundary;
    044        private FilePart lastFilePart;
    045        private final byte[] buf = new byte[8 * 1024];
    046        private static final String DEFAULT_ENCODING = "MS932";
    047        private String encoding = DEFAULT_ENCODING;
    048
    049        /**
    050         * マルチパート処理のパーサーオブジェクトを構築する、コンストラクター
    051         *
    052         * @og.rev 5.3.7.0 (2011/07/01) 最大容量オーバー時のエラーメッセージ変更
    053         * @og.rev 5.5.2.6 (2012/05/25) maxSize で、0,またはマイナスで無制限
    054         * @og.rev 6.9.0.0 (2018/01/31) multipart 判定方法の変更
    055         * @og.rev 7.4.2.0 (2021/04/30) Microsoft Edge は、boundary の取り方が違うので、その対応。
    056         *
    057         * @param       req             HttpServletRequestオブジェクト
    058         * @param       maxSize 最大容量(0,またはマイナスで無制限)
    059         * @throws IOException 入出力エラーが発生したとき
    060         */
    061        public MultipartParser( final HttpServletRequest req, final int maxSize ) throws IOException {
    062//              String type = null;
    063                final String type1 = req.getHeader("Content-Type");
    064                final String type2 = req.getContentType();
    065
    066                final String type = type1 != null && type2 != null && type1.length() < type2.length()
    067                                                                ? type2
    068                                                                : StringUtil.nval( type1,type2 );
    069
    070                // 6.9.0.0 (2018/01/31) multipart 判定方法の変更
    071//              if( type1 == null && type2 != null ) {
    072//                      type = type2;
    073//              }
    074//              else if( type2 == null && type1 != null ) {
    075//                      type = type1;
    076//              }
    077//              else if( type1 != null && type2 != null ) {
    078//                      type = (type1.length() > type2.length() ? type1 : type2);
    079//              }
    080
    081                // 6.9.0.0 (2018/01/31) multipart 判定方法の変更
    082                if( type == null || !type.toLowerCase(Locale.JAPAN).startsWith("multipart/form-data") ) {
    083//                      throw new IOException("Posted content type isn't multipart/form-data");
    084                        final String errMsg = "Posted content type isn't multipart/form-data" + CR
    085                                                                        + "Content-Type=" + type ;
    086                        throw new IOException( errMsg );
    087                }
    088
    089                final int length = req.getContentLength();
    090                // 5.5.2.6 (2012/05/25) maxSize で、0,またはマイナスで無制限
    091                if( maxSize > 0 && length > maxSize ) {
    092                        throw new IOException("登録したファイルサイズが上限(" + ( maxSize / 1024 / 1024 ) + "MB)を越えています。"
    093                                                                        + " 登録ファイル=" + ( length / 1024 / 1024 ) + "MB" ); // 5.3.7.0 (2011/07/01)
    094                }
    095
    096                // 4.0.0 (2005/01/31) The local variable "boundary" shadows an accessible field with the same name and compatible type in class org.opengion.hayabusa.servlet.multipart.MultipartParser
    097                // 7.4.2.0 (2021/04/30) Microsoft Edge は、boundary の取り方が違うので、その対応。
    098                String bound = extractBoundary(type);
    099//              final String bound = extractBoundary(type);
    100//              if( bound == null ) {
    101//                      throw new IOException("Separation boundary was not specified");
    102//              }
    103
    104                this.in = req.getInputStream();
    105        //      this.boundary = bound;                                  // 7.4.2.0 (2021/04/30)
    106
    107                final String line = readLine();
    108                if( line == null ) {
    109                        throw new IOException("Corrupt form data: premature ending");
    110                }
    111                // 7.4.2.0 (2021/04/30) Microsoft Edge は、boundary の取り方が違うので、その対応。
    112                else if( bound == null && line.contains( "WebKitFormBoundary" ) ) {
    113                        bound = line;
    114                }
    115
    116                this.boundary = bound;                                  // 7.4.2.0 (2021/04/30)
    117
    118                if( !line.startsWith(boundary) ) {
    119                        throw new IOException("Corrupt form data: no leading boundary: " +
    120                                                                                                                line + " != " + boundary);
    121                }
    122        }
    123
    124        /**
    125         * エンコードを設定します。
    126         *
    127         * @param  encoding エンコード
    128         */
    129        public void setEncoding( final String encoding ) {
    130                 this.encoding = encoding;
    131         }
    132
    133        /**
    134         * 次のパートを読み取ります。
    135         *
    136         * @og.rev 3.5.6.2 (2004/07/05) 文字列の連結にStringBuilderを使用します。
    137         *
    138         * @return      次のパート
    139         * @throws IOException 入出力エラーが発生したとき
    140         */
    141        public Part readNextPart() throws IOException {
    142                if( lastFilePart != null ) {
    143                        Closer.ioClose( lastFilePart.getInputStream() );                // 4.0.0 (2006/01/31) close 処理時の IOException を無視
    144                        lastFilePart = null;
    145                }
    146
    147                String line = readLine();
    148                if( line == null || line.isEmpty() ) { return null; }
    149
    150                final StringBuilder buf = new StringBuilder( BUFFER_MIDDLE );   // 6.1.0.0 (2014/12/26) refactoring
    151                final List<String> headers = new ArrayList<>();
    152                while( line != null && line.length() > 0 ) {
    153                        String nextLine = null;
    154                        boolean getNextLine = true;
    155                        buf.setLength(0);                                                                       // 6.1.0.0 (2014/12/26) refactoring
    156                        buf.append( line );
    157                        while( getNextLine ) {
    158                                nextLine = readLine();
    159
    160                                // 6.1.0.0 (2014/12/26) refactoring
    161                                if( nextLine != null && nextLine.length() > 0 && ( nextLine.charAt(0) == ' ' || nextLine.charAt(0) == '\t' ) ) {
    162                                        buf.append( nextLine );
    163                                }
    164                                else {
    165                                        getNextLine = false;
    166                                }
    167                        }
    168
    169                        headers.add(buf.toString());
    170                        line = nextLine;
    171                }
    172
    173                if( line == null ) {
    174                        return null;
    175                }
    176
    177                String name             = null;
    178                String filename = null;
    179                String origname = null;
    180                String contentType = "text/plain";
    181
    182                for( final String headerline : headers ) {
    183                        if( headerline.toLowerCase(Locale.JAPAN).startsWith("content-disposition:") ) {
    184                                final String[] dispInfo = extractDispositionInfo(headerline);
    185
    186                                name = dispInfo[1];
    187                                filename = dispInfo[2];
    188                                origname = dispInfo[3];
    189                        }
    190                        else if( headerline.toLowerCase(Locale.JAPAN).startsWith("content-type:") ) {
    191                                final String type = extractContentType(headerline);
    192                                if( type != null ) {
    193                                        contentType = type;
    194                                }
    195                        }
    196                }
    197
    198                if( filename == null ) {
    199                        return new ParamPart(name, in, boundary, encoding);
    200                }
    201                else {
    202                        if( "".equals( filename ) ) {
    203                                filename = null;
    204                        }
    205                        lastFilePart = new FilePart(name,in,boundary,contentType,filename,origname);
    206                        return lastFilePart;
    207                }
    208        }
    209
    210        /**
    211         * ローカル変数「境界」アクセス可能なフィールドを返します。
    212         *
    213         * @og.rev 6.9.0.0 (2018/01/31) HttpConnect 使用時のﾌｧｲﾙ名の文字化け対策
    214         *
    215         * @param       line    １行
    216         *
    217         * @return      境界文字列
    218         * @see         org.opengion.hayabusa.servlet.multipart.MultipartParser
    219         */
    220        private String extractBoundary( final String line ) {
    221                // 4.0.0 (2005/01/31) The local variable "boundary" shadows an accessible field with the same name and compatible type in class org.opengion.hayabusa.servlet.multipart.MultipartParser
    222                int index = line.lastIndexOf("boundary=");
    223                if( index == -1 ) {
    224                        return null;
    225                }
    226                String bound = line.substring(index + 9);
    227                if( bound.charAt(0) == '"' ) {
    228                        index = bound.lastIndexOf('"');
    229                        bound = bound.substring(1, index);
    230                }
    231
    232                // 6.9.0.0 (2018/01/31) HttpConnect 使用時のﾌｧｲﾙ名の文字化け対策
    233                // HttpConnect で、MultipartEntityBuilder でﾌｧｲﾙをｱｯﾌﾟﾛｰﾄﾞするとき、
    234                // 日本語ﾌｧｲﾙ名が文字化けするため、setCharset で、UTF-8 指定しますが、
    235                // "; charset=UTF-8" という文字列がMIME変換文字にセットされる(バグ？)
    236                // のような動きをしており、強制的に削除しています。
    237                final int ad = bound.indexOf( "; charset=UTF-8" );
    238                if( ad >= 0 ) { bound=bound.substring( 0,ad ); }
    239
    240                bound = "--" + bound;
    241
    242                return bound;
    243        }
    244
    245        /**
    246         * コンテンツの情報を返します。
    247         *
    248         * @param       origline        元の行
    249         *
    250         * @return      コンテンツの情報配列
    251         * @throws IOException 入出力エラーが発生したとき
    252         */
    253        private String[] extractDispositionInfo( final String origline ) throws IOException {
    254
    255                final String line = origline.toLowerCase(Locale.JAPAN);
    256
    257                int start = line.indexOf( "content-disposition: " );
    258                int end = line.indexOf(';');
    259                if( start == -1 || end == -1 ) {
    260                        throw new IOException( "Content disposition corrupt: " + origline );
    261                }
    262                final String disposition = line.substring( start + 21, end );
    263                if( !"form-data".equals(disposition) ) {
    264                        throw new IOException("Invalid content disposition: " + disposition);
    265                }
    266
    267                start = line.indexOf("name=\"", end);   // start at last semicolon
    268                end = line.indexOf( '"', start + 7);    // 6.0.2.5 (2014/10/31) refactoring skip name=\"
    269                if( start == -1 || end == -1 ) {
    270                        throw new IOException("Content disposition corrupt: " + origline);
    271                }
    272                final String name = origline.substring(start + 6, end);
    273
    274                String filename = null;
    275                String origname = null;
    276                start = line.indexOf("filename=\"", end + 2);   // start after name
    277                end = line.indexOf( '"', start + 10);                   // skip filename=\"
    278                if( start != -1 && end != -1 ) {                                        // note the !=
    279                        filename = origline.substring(start + 10, end);
    280                        origname = filename;
    281                        final int slash =
    282                                Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\'));
    283                        if( slash > -1 ) {
    284                                filename = filename.substring(slash + 1);       // past last slash
    285                        }
    286                }
    287
    288                String[] retval = new String[4];        // 6.1.0.0 (2014/12/26) refactoring
    289                retval[0] = disposition;
    290                retval[1] = name;
    291                retval[2] = filename;
    292                retval[3] = origname;
    293                return retval;
    294        }
    295
    296        /**
    297         * コンテンツタイプの情報を返します。
    298         *
    299         * @param       origline        元の行
    300         *
    301         * @return      コンテンツタイプの情報
    302         * @throws IOException 入出力エラーが発生したとき
    303         */
    304        private String extractContentType( final String origline ) throws IOException {
    305                String contentType = null;
    306
    307                final String line = origline.toLowerCase(Locale.JAPAN);
    308
    309                if( line.startsWith("content-type") ) {
    310                        final int start = line.indexOf(' ');
    311                        if( start == -1 ) {
    312                                throw new IOException("Content type corrupt: " + origline);
    313                        }
    314                        contentType = line.substring(start + 1);
    315                }
    316                else if( line.length() > 0 ) {  // no content type, so should be empty
    317                        throw new IOException("Malformed line after disposition: " + origline);
    318                }
    319
    320                return contentType;
    321        }
    322
    323        /**
    324         * 行を読み取ります。
    325         *
    326         * @return      読み取られた１行分
    327         * @throws IOException 入出力エラーが発生したとき
    328         */
    329        private String readLine() throws IOException {
    330                final StringBuilder sbuf = new StringBuilder( BUFFER_MIDDLE );
    331                int result;
    332
    333                do {
    334                        result = in.readLine(buf, 0, buf.length);
    335                        if( result != -1 ) {
    336                                sbuf.append(new String(buf, 0, result, encoding));
    337                        }
    338                } while( result == buf.length );
    339
    340                if( sbuf.length() == 0 ) {
    341                        return null;
    342                }
    343
    344                // 4.0.0 (2005/01/31) The method StringBuilder.setLength() should be avoided in favor of creating a new StringBuilder.
    345                String rtn = sbuf.toString();
    346                final int len = sbuf.length();
    347                if( len >= 2 && sbuf.charAt(len - 2) == '\r' ) {
    348                        rtn = rtn.substring(0,len - 2);
    349                }
    350                else if( len >= 1 && sbuf.charAt(len - 1) == '\n' ) {
    351                        rtn = rtn.substring(0,len - 1);
    352                }
    353
    354                return rtn ;
    355        }
    356}