View Javadoc
1   /*
2    * Copyright 2012 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package io.netty.handler.codec.http.multipart;
17  
18  import io.netty.buffer.ByteBuf;
19  import io.netty.buffer.Unpooled;
20  import io.netty.handler.codec.http.HttpConstants;
21  import io.netty.handler.codec.http.HttpContent;
22  import io.netty.handler.codec.http.HttpRequest;
23  import io.netty.handler.codec.http.LastHttpContent;
24  import io.netty.handler.codec.http.QueryStringDecoder;
25  import io.netty.handler.codec.http.HttpHeaderValues;
26  import io.netty.handler.codec.http.HttpHeaderNames;
27  import io.netty.handler.codec.http.multipart.HttpPostBodyUtil.SeekAheadOptimize;
28  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.EndOfDataDecoderException;
29  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.ErrorDataDecoderException;
30  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.MultiPartStatus;
31  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.NotEnoughDataDecoderException;
32  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.TooManyFormFieldsException;
33  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.TooLongFormFieldException;
34  import io.netty.util.ByteProcessor;
35  import io.netty.util.internal.PlatformDependent;
36  import io.netty.util.internal.StringUtil;
37  
38  import java.io.IOException;
39  import java.nio.charset.Charset;
40  import java.util.ArrayList;
41  import java.util.List;
42  import java.util.Map;
43  import java.util.TreeMap;
44  
45  import static io.netty.util.internal.ObjectUtil.*;
46  
47  /**
48   * This decoder will decode Body and can handle POST BODY.
49   *
50   * You <strong>MUST</strong> call {@link #destroy()} after completion to release all resources.
51   *
52   */
53  public class HttpPostStandardRequestDecoder implements InterfaceHttpPostRequestDecoder {
54  
55      /**
56       * Factory used to create InterfaceHttpData
57       */
58      private final HttpDataFactory factory;
59  
60      /**
61       * Request to decode
62       */
63      private final HttpRequest request;
64  
65      /**
66       * Default charset to use
67       */
68      private final Charset charset;
69  
70      /**
71       * The maximum number of fields allows by the form
72       */
73      private final int maxFields;
74  
75      /**
76       * The maximum number of accumulated bytes when decoding a field
77       */
78      private final int maxBufferedBytes;
79  
80      /**
81       * Does the last chunk already received
82       */
83      private boolean isLastChunk;
84  
85      /**
86       * HttpDatas from Body
87       */
88      private final List<InterfaceHttpData> bodyListHttpData = new ArrayList<InterfaceHttpData>();
89  
90      /**
91       * HttpDatas as Map from Body
92       */
93      private final Map<String, List<InterfaceHttpData>> bodyMapHttpData = new TreeMap<String, List<InterfaceHttpData>>(
94              CaseIgnoringComparator.INSTANCE);
95  
96      /**
97       * The current channelBuffer
98       */
99      private ByteBuf undecodedChunk;
100 
101     /**
102      * Body HttpDatas current position
103      */
104     private int bodyListHttpDataRank;
105 
106     /**
107      * Current getStatus
108      */
109     private MultiPartStatus currentStatus = MultiPartStatus.NOTSTARTED;
110 
111     /**
112      * The current Attribute that is currently in decode process
113      */
114     private Attribute currentAttribute;
115 
116     private boolean destroyed;
117 
118     private int discardThreshold = HttpPostRequestDecoder.DEFAULT_DISCARD_THRESHOLD;
119 
120     /**
121      *
122      * @param request
123      *            the request to decode
124      * @throws NullPointerException
125      *             for request
126      * @throws ErrorDataDecoderException
127      *             if the default charset was wrong when decoding or other
128      *             errors
129      */
130     public HttpPostStandardRequestDecoder(HttpRequest request) {
131         this(new DefaultHttpDataFactory(DefaultHttpDataFactory.MINSIZE), request, HttpConstants.DEFAULT_CHARSET);
132     }
133 
134     /**
135      *
136      * @param factory
137      *            the factory used to create InterfaceHttpData
138      * @param request
139      *            the request to decode
140      * @throws NullPointerException
141      *             for request or factory
142      * @throws ErrorDataDecoderException
143      *             if the default charset was wrong when decoding or other
144      *             errors
145      */
146     public HttpPostStandardRequestDecoder(HttpDataFactory factory, HttpRequest request) {
147         this(factory, request, HttpConstants.DEFAULT_CHARSET);
148     }
149 
150     /**
151      *
152      * @param factory
153      *            the factory used to create InterfaceHttpData
154      * @param request
155      *            the request to decode
156      * @param charset
157      *            the charset to use as default
158      * @throws NullPointerException
159      *             for request or charset or factory
160      * @throws ErrorDataDecoderException
161      *             if the default charset was wrong when decoding or other
162      *             errors
163      */
164     public HttpPostStandardRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset) {
165         this(factory, request, charset, HttpPostRequestDecoder.DEFAULT_MAX_FIELDS,
166                 HttpPostRequestDecoder.DEFAULT_MAX_BUFFERED_BYTES);
167     }
168 
169     /**
170      *
171      * @param factory
172      *            the factory used to create InterfaceHttpData
173      * @param request
174      *            the request to decode
175      * @param charset
176      *            the charset to use as default
177      * @param maxFields
178      *            the maximum number of fields the form can have, {@code -1} to disable
179      * @param maxBufferedBytes
180      *            the maximum number of bytes the decoder can buffer when decoding a field, {@code -1} to disable
181      * @throws NullPointerException
182      *             for request or charset or factory
183      * @throws ErrorDataDecoderException
184      *             if the default charset was wrong when decoding or other
185      *             errors
186      */
187     public HttpPostStandardRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset,
188                                           int maxFields, int maxBufferedBytes) {
189         this.request = checkNotNull(request, "request");
190         this.charset = checkNotNull(charset, "charset");
191         this.factory = checkNotNull(factory, "factory");
192         this.maxFields = maxFields;
193         this.maxBufferedBytes = maxBufferedBytes;
194         try {
195             if (request instanceof HttpContent) {
196                 // Offer automatically if the given request is as type of HttpContent
197                 // See #1089
198                 offer((HttpContent) request);
199             } else {
200                 parseBody();
201             }
202         } catch (Throwable e) {
203             destroy();
204             PlatformDependent.throwException(e);
205         }
206     }
207 
208     private void checkDestroyed() {
209         if (destroyed) {
210             throw new IllegalStateException(HttpPostStandardRequestDecoder.class.getSimpleName()
211                     + " was destroyed already");
212         }
213     }
214 
215     /**
216      * True if this request is a Multipart request
217      *
218      * @return True if this request is a Multipart request
219      */
220     @Override
221     public boolean isMultipart() {
222         checkDestroyed();
223         return false;
224     }
225 
226     /**
227      * Set the amount of bytes after which read bytes in the buffer should be discarded.
228      * Setting this lower gives lower memory usage but with the overhead of more memory copies.
229      * Use {@code 0} to disable it.
230      */
231     @Override
232     public void setDiscardThreshold(int discardThreshold) {
233         this.discardThreshold = checkPositiveOrZero(discardThreshold, "discardThreshold");
234     }
235 
236     /**
237      * Return the threshold in bytes after which read data in the buffer should be discarded.
238      */
239     @Override
240     public int getDiscardThreshold() {
241         return discardThreshold;
242     }
243 
244     /**
245      * This getMethod returns a List of all HttpDatas from body.<br>
246      *
247      * If chunked, all chunks must have been offered using offer() getMethod. If
248      * not, NotEnoughDataDecoderException will be raised.
249      *
250      * @return the list of HttpDatas from Body part for POST getMethod
251      * @throws NotEnoughDataDecoderException
252      *             Need more chunks
253      */
254     @Override
255     public List<InterfaceHttpData> getBodyHttpDatas() {
256         checkDestroyed();
257 
258         if (!isLastChunk) {
259             throw new NotEnoughDataDecoderException();
260         }
261         return bodyListHttpData;
262     }
263 
264     /**
265      * This getMethod returns a List of all HttpDatas with the given name from
266      * body.<br>
267      *
268      * If chunked, all chunks must have been offered using offer() getMethod. If
269      * not, NotEnoughDataDecoderException will be raised.
270      *
271      * @return All Body HttpDatas with the given name (ignore case)
272      * @throws NotEnoughDataDecoderException
273      *             need more chunks
274      */
275     @Override
276     public List<InterfaceHttpData> getBodyHttpDatas(String name) {
277         checkDestroyed();
278 
279         if (!isLastChunk) {
280             throw new NotEnoughDataDecoderException();
281         }
282         return bodyMapHttpData.get(name);
283     }
284 
285     /**
286      * This getMethod returns the first InterfaceHttpData with the given name from
287      * body.<br>
288      *
289      * If chunked, all chunks must have been offered using offer() getMethod. If
290      * not, NotEnoughDataDecoderException will be raised.
291      *
292      * @return The first Body InterfaceHttpData with the given name (ignore
293      *         case)
294      * @throws NotEnoughDataDecoderException
295      *             need more chunks
296      */
297     @Override
298     public InterfaceHttpData getBodyHttpData(String name) {
299         checkDestroyed();
300 
301         if (!isLastChunk) {
302             throw new NotEnoughDataDecoderException();
303         }
304         List<InterfaceHttpData> list = bodyMapHttpData.get(name);
305         if (list != null) {
306             return list.get(0);
307         }
308         return null;
309     }
310 
311     /**
312      * Initialized the internals from a new chunk
313      *
314      * @param content
315      *            the new received chunk
316      * @throws ErrorDataDecoderException
317      *             if there is a problem with the charset decoding or other
318      *             errors
319      */
320     @Override
321     public HttpPostStandardRequestDecoder offer(HttpContent content) {
322         checkDestroyed();
323 
324         if (content instanceof LastHttpContent) {
325             isLastChunk = true;
326         }
327 
328         ByteBuf buf = content.content();
329         if (undecodedChunk == null) {
330             undecodedChunk =
331                     // Since the Handler will release the incoming later on, we need to copy it
332                     //
333                     // We are explicit allocate a buffer and NOT calling copy() as otherwise it may set a maxCapacity
334                     // which is not really usable for us as we may exceed it once we add more bytes.
335                     buf.alloc().buffer(buf.readableBytes()).writeBytes(buf);
336         } else {
337             undecodedChunk.writeBytes(buf);
338         }
339         parseBody();
340         if (maxBufferedBytes > 0 && undecodedChunk != null && undecodedChunk.readableBytes() > maxBufferedBytes) {
341             throw new TooLongFormFieldException();
342         }
343         if (undecodedChunk != null && undecodedChunk.writerIndex() > discardThreshold) {
344             if (undecodedChunk.refCnt() == 1) {
345                 // It's safe to call discardBytes() as we are the only owner of the buffer.
346                 undecodedChunk.discardReadBytes();
347             } else {
348                 // There seems to be multiple references of the buffer. Let's copy the data and release the buffer to
349                 // ensure we can give back memory to the system.
350                 ByteBuf buffer = undecodedChunk.alloc().buffer(undecodedChunk.readableBytes());
351                 buffer.writeBytes(undecodedChunk);
352                 undecodedChunk.release();
353                 undecodedChunk = buffer;
354             }
355         }
356         return this;
357     }
358 
359     /**
360      * True if at current getStatus, there is an available decoded
361      * InterfaceHttpData from the Body.
362      *
363      * This getMethod works for chunked and not chunked request.
364      *
365      * @return True if at current getStatus, there is a decoded InterfaceHttpData
366      * @throws EndOfDataDecoderException
367      *             No more data will be available
368      */
369     @Override
370     public boolean hasNext() {
371         checkDestroyed();
372 
373         if (currentStatus == MultiPartStatus.EPILOGUE) {
374             // OK except if end of list
375             if (bodyListHttpDataRank >= bodyListHttpData.size()) {
376                 throw new EndOfDataDecoderException();
377             }
378         }
379         return !bodyListHttpData.isEmpty() && bodyListHttpDataRank < bodyListHttpData.size();
380     }
381 
382     /**
383      * Returns the next available InterfaceHttpData or null if, at the time it
384      * is called, there is no more available InterfaceHttpData. A subsequent
385      * call to offer(httpChunk) could enable more data.
386      *
387      * Be sure to call {@link InterfaceHttpData#release()} after you are done
388      * with processing to make sure to not leak any resources
389      *
390      * @return the next available InterfaceHttpData or null if none
391      * @throws EndOfDataDecoderException
392      *             No more data will be available
393      */
394     @Override
395     public InterfaceHttpData next() {
396         checkDestroyed();
397 
398         if (hasNext()) {
399             return bodyListHttpData.get(bodyListHttpDataRank++);
400         }
401         return null;
402     }
403 
404     @Override
405     public InterfaceHttpData currentPartialHttpData() {
406         return currentAttribute;
407     }
408 
409     /**
410      * This getMethod will parse as much as possible data and fill the list and map
411      *
412      * @throws ErrorDataDecoderException
413      *             if there is a problem with the charset decoding or other
414      *             errors
415      */
416     private void parseBody() {
417         if (currentStatus == MultiPartStatus.PREEPILOGUE || currentStatus == MultiPartStatus.EPILOGUE) {
418             if (isLastChunk) {
419                 currentStatus = MultiPartStatus.EPILOGUE;
420             }
421             return;
422         }
423         parseBodyAttributes();
424     }
425 
426     /**
427      * Utility function to add a new decoded data
428      */
429     protected void addHttpData(InterfaceHttpData data) {
430         if (data == null) {
431             return;
432         }
433         if (maxFields > 0 && bodyListHttpData.size() >= maxFields) {
434             throw new TooManyFormFieldsException();
435         }
436         List<InterfaceHttpData> datas = bodyMapHttpData.get(data.getName());
437         if (datas == null) {
438             datas = new ArrayList<InterfaceHttpData>(1);
439             bodyMapHttpData.put(data.getName(), datas);
440         }
441         datas.add(data);
442         bodyListHttpData.add(data);
443     }
444 
445     /**
446      * This getMethod fill the map and list with as much Attribute as possible from
447      * Body in not Multipart mode.
448      *
449      * @throws ErrorDataDecoderException
450      *             if there is a problem with the charset decoding or other
451      *             errors
452      */
453     private void parseBodyAttributesStandard() {
454         int firstpos = undecodedChunk.readerIndex();
455         int currentpos = firstpos;
456         int equalpos;
457         int ampersandpos;
458         if (currentStatus == MultiPartStatus.NOTSTARTED) {
459             currentStatus = MultiPartStatus.DISPOSITION;
460         }
461         boolean contRead = true;
462         try {
463             while (undecodedChunk.isReadable() && contRead) {
464                 char read = (char) undecodedChunk.readUnsignedByte();
465                 currentpos++;
466                 switch (currentStatus) {
467                 case DISPOSITION:// search '='
468                     if (read == '=') {
469                         currentStatus = MultiPartStatus.FIELD;
470                         equalpos = currentpos - 1;
471                         String key = decodeAttribute(undecodedChunk.toString(firstpos, equalpos - firstpos, charset),
472                                 charset);
473                         currentAttribute = factory.createAttribute(request, key);
474                         firstpos = currentpos;
475                     } else if (read == '&' ||
476                             (isLastChunk && !undecodedChunk.isReadable() && hasFormBody())) { // special empty FIELD
477                         currentStatus = MultiPartStatus.DISPOSITION;
478                         ampersandpos = read == '&' ? currentpos - 1 : currentpos;
479                         String key = decodeAttribute(
480                                 undecodedChunk.toString(firstpos, ampersandpos - firstpos, charset), charset);
481                         // Some weird request bodies start with an '&' character, eg: &name=J&age=17.
482                         // In that case, key would be "", will get exception:
483                         // java.lang.IllegalArgumentException: Param 'name' must not be empty;
484                         // Just check and skip empty key.
485                         if (!key.isEmpty()) {
486                             currentAttribute = factory.createAttribute(request, key);
487                             currentAttribute.setValue(""); // empty
488                             addHttpData(currentAttribute);
489                         }
490                         currentAttribute = null;
491                         firstpos = currentpos;
492                         contRead = true;
493                     }
494                     break;
495                 case FIELD:// search '&' or end of line
496                     if (read == '&') {
497                         currentStatus = MultiPartStatus.DISPOSITION;
498                         ampersandpos = currentpos - 1;
499                         setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
500                         firstpos = currentpos;
501                         contRead = true;
502                     } else if (read == HttpConstants.CR) {
503                         if (undecodedChunk.isReadable()) {
504                             read = (char) undecodedChunk.readUnsignedByte();
505                             currentpos++;
506                             if (read == HttpConstants.LF) {
507                                 currentStatus = MultiPartStatus.PREEPILOGUE;
508                                 ampersandpos = currentpos - 2;
509                                 setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
510                                 firstpos = currentpos;
511                                 contRead = false;
512                             } else {
513                                 // Error
514                                 throw new ErrorDataDecoderException("Bad end of line");
515                             }
516                         } else {
517                             currentpos--;
518                         }
519                     } else if (read == HttpConstants.LF) {
520                         currentStatus = MultiPartStatus.PREEPILOGUE;
521                         ampersandpos = currentpos - 1;
522                         setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
523                         firstpos = currentpos;
524                         contRead = false;
525                     }
526                     break;
527                 default:
528                     // just stop
529                     contRead = false;
530                 }
531             }
532             if (isLastChunk && currentAttribute != null) {
533                 // special case
534                 ampersandpos = currentpos;
535                 if (ampersandpos > firstpos) {
536                     setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
537                 } else if (!currentAttribute.isCompleted()) {
538                     setFinalBuffer(Unpooled.EMPTY_BUFFER);
539                 }
540                 firstpos = currentpos;
541                 currentStatus = MultiPartStatus.EPILOGUE;
542             } else if (contRead && currentAttribute != null && currentStatus == MultiPartStatus.FIELD) {
543                 // reset index except if to continue in case of FIELD getStatus
544                 currentAttribute.addContent(undecodedChunk.retainedSlice(firstpos, currentpos - firstpos),
545                                             false);
546                 firstpos = currentpos;
547             }
548             undecodedChunk.readerIndex(firstpos);
549         } catch (ErrorDataDecoderException e) {
550             // error while decoding
551             undecodedChunk.readerIndex(firstpos);
552             throw e;
553         } catch (IOException e) {
554             // error while decoding
555             undecodedChunk.readerIndex(firstpos);
556             throw new ErrorDataDecoderException(e);
557         } catch (IllegalArgumentException e) {
558             // error while decoding
559             undecodedChunk.readerIndex(firstpos);
560             throw new ErrorDataDecoderException(e);
561         }
562     }
563 
564     /**
565      * This getMethod fill the map and list with as much Attribute as possible from
566      * Body in not Multipart mode.
567      *
568      * @throws ErrorDataDecoderException
569      *             if there is a problem with the charset decoding or other
570      *             errors
571      */
572     private void parseBodyAttributes() {
573         if (undecodedChunk == null) {
574             return;
575         }
576         parseBodyAttributesStandard();
577     }
578 
579     private void setFinalBuffer(ByteBuf buffer) throws IOException {
580         currentAttribute.addContent(buffer, true);
581         ByteBuf decodedBuf = decodeAttribute(currentAttribute.getByteBuf(), charset);
582         if (decodedBuf != null) { // override content only when ByteBuf needed decoding
583             currentAttribute.setContent(decodedBuf);
584         }
585         addHttpData(currentAttribute);
586         currentAttribute = null;
587     }
588 
589     /**
590      * Decode component
591      *
592      * @return the decoded component
593      */
594     private static String decodeAttribute(String s, Charset charset) {
595         try {
596             return QueryStringDecoder.decodeComponent(s, charset);
597         } catch (IllegalArgumentException e) {
598             throw new ErrorDataDecoderException("Bad string: '" + s + '\'', e);
599         }
600     }
601 
602     private static ByteBuf decodeAttribute(ByteBuf b, Charset charset) {
603         int firstEscaped = b.forEachByte(new UrlEncodedDetector());
604         if (firstEscaped == -1) {
605             return null; // nothing to decode
606         }
607 
608         ByteBuf buf = b.alloc().buffer(b.readableBytes());
609         UrlDecoder urlDecode = new UrlDecoder(buf);
610         int idx = b.forEachByte(urlDecode);
611         if (urlDecode.nextEscapedIdx != 0) { // incomplete hex byte
612             if (idx == -1) {
613                 idx = b.readableBytes() - 1;
614             }
615             idx -= urlDecode.nextEscapedIdx - 1;
616             buf.release();
617             throw new ErrorDataDecoderException(
618                 String.format("Invalid hex byte at index '%d' in string: '%s'", idx, b.toString(charset)));
619         }
620 
621         return buf;
622     }
623 
624     /**
625      * Destroy the {@link HttpPostStandardRequestDecoder} and release all it resources. After this method
626      * was called it is not possible to operate on it anymore.
627      */
628     @Override
629     public void destroy() {
630         // Release all data items, including those not yet pulled, only file based items
631         cleanFiles();
632         // Clean Memory based data
633         for (InterfaceHttpData httpData : bodyListHttpData) {
634             // Might have been already released by the user
635             if (httpData.refCnt() > 0) {
636                 httpData.release();
637             }
638         }
639 
640         destroyed = true;
641 
642         if (undecodedChunk != null && undecodedChunk.refCnt() > 0) {
643             undecodedChunk.release();
644             undecodedChunk = null;
645         }
646     }
647 
648     /**
649      * Clean all {@link HttpData}s for the current request.
650      */
651     @Override
652     public void cleanFiles() {
653         checkDestroyed();
654 
655         factory.cleanRequestHttpData(request);
656     }
657 
658     /**
659      * Remove the given FileUpload from the list of FileUploads to clean
660      */
661     @Override
662     public void removeHttpDataFromClean(InterfaceHttpData data) {
663         checkDestroyed();
664 
665         factory.removeHttpDataFromClean(request, data);
666     }
667 
668     /**
669      * Check if request has headers indicating that it contains form body
670      */
671     private boolean hasFormBody() {
672         String contentHeaderValue = request.headers().get(HttpHeaderNames.CONTENT_TYPE);
673         if (contentHeaderValue == null) {
674             return false;
675         }
676         return HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.contentEquals(contentHeaderValue)
677                 || HttpHeaderValues.MULTIPART_FORM_DATA.contentEquals(contentHeaderValue);
678     }
679 
680     private static final class UrlEncodedDetector implements ByteProcessor {
681         @Override
682         public boolean process(byte value) throws Exception {
683             return value != '%' && value != '+';
684         }
685     }
686 
687     private static final class UrlDecoder implements ByteProcessor {
688 
689         private final ByteBuf output;
690         private int nextEscapedIdx;
691         private byte hiByte;
692 
693         UrlDecoder(ByteBuf output) {
694             this.output = output;
695         }
696 
697         @Override
698         public boolean process(byte value) {
699             if (nextEscapedIdx != 0) {
700                 if (nextEscapedIdx == 1) {
701                     hiByte = value;
702                     ++nextEscapedIdx;
703                 } else {
704                     int hi = StringUtil.decodeHexNibble((char) hiByte);
705                     int lo = StringUtil.decodeHexNibble((char) value);
706                     if (hi == -1 || lo == -1) {
707                         ++nextEscapedIdx;
708                         return false;
709                     }
710                     output.writeByte((hi << 4) + lo);
711                     nextEscapedIdx = 0;
712                 }
713             } else if (value == '%') {
714                 nextEscapedIdx = 1;
715             } else if (value == '+') {
716                 output.writeByte(' ');
717             } else {
718                 output.writeByte(value);
719             }
720             return true;
721         }
722     }
723 }