View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.javascript.host.file;
16  
17  import static java.nio.charset.StandardCharsets.UTF_8;
18  
19  import java.io.ByteArrayOutputStream;
20  import java.io.IOException;
21  import java.io.Serializable;
22  import java.nio.charset.Charset;
23  import java.util.Locale;
24  
25  import org.htmlunit.BrowserVersion;
26  import org.htmlunit.HttpHeader;
27  import org.htmlunit.WebRequest;
28  import org.htmlunit.corejs.javascript.NativeArray;
29  import org.htmlunit.corejs.javascript.NativePromise;
30  import org.htmlunit.corejs.javascript.Scriptable;
31  import org.htmlunit.corejs.javascript.ScriptableObject;
32  import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBuffer;
33  import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBufferView;
34  import org.htmlunit.javascript.HtmlUnitScriptable;
35  import org.htmlunit.javascript.JavaScriptEngine;
36  import org.htmlunit.javascript.configuration.JsxClass;
37  import org.htmlunit.javascript.configuration.JsxConstructor;
38  import org.htmlunit.javascript.configuration.JsxFunction;
39  import org.htmlunit.javascript.configuration.JsxGetter;
40  import org.htmlunit.javascript.host.ReadableStream;
41  import org.htmlunit.util.KeyDataPair;
42  import org.htmlunit.util.MimeType;
43  import org.htmlunit.util.StringUtils;
44  
45  /**
46   * A JavaScript object for {@code Blob}.
47   *
48   * @author Ahmed Ashour
49   * @author Ronald Brill
50   * @author Lai Quang Duong
51   */
52  @JsxClass
53  public class Blob extends HtmlUnitScriptable {
54      private static final String OPTIONS_TYPE_NAME = "type";
55      //default according to https://developer.mozilla.org/en-US/docs/Web/API/File/File
56      private static final String OPTIONS_TYPE_DEFAULT = "";
57      private static final String OPTIONS_LASTMODIFIED = "lastModified";
58  
59      private Backend backend_;
60  
61      /**
62       * The backend used for saving the blob.
63       */
64      protected abstract static class Backend implements Serializable {
65  
66          /**
67           * Returns the name.
68           *
69           * @return the name
70           */
71          abstract String getName();
72  
73          /**
74           * Returns the last modified timestamp as long.
75           *
76           * @return the last modified timestamp as long
77           */
78          abstract long getLastModified();
79  
80          /**
81           * Returns the size.
82           *
83           * @return the size
84           */
85          abstract long getSize();
86  
87          /**
88           * Returns the type.
89           *
90           * @param browserVersion the {@link BrowserVersion}
91           * @return the type
92           */
93          abstract String getType(BrowserVersion browserVersion);
94  
95          /**
96           * Returns the text.
97           *
98           * @return the text
99           * @throws IOException in case of error
100          */
101         abstract String getText() throws IOException;
102 
103         /**
104          * Returns the bytes.
105          *
106          * @param start the start position
107          * @param end the end position
108          * @return the bytes
109          */
110         abstract byte[] getBytes(int start, int end);
111 
112         /**
113          * Ctor.
114          */
115         Backend() {
116             // to make it package protected
117         }
118 
119         /**
120          * Returns the KeyDataPair for this Blob/File.
121          *
122          * @param name the name
123          * @param fileName the file name
124          * @param contentType the content type
125          * @return the KeyDataPair to hold the data
126          */
127         abstract KeyDataPair getKeyDataPair(String name, String fileName, String contentType);
128     }
129 
130     /**
131      * Implementation of the {@link Backend} that stores the bytes in memory.
132      *
133      */
134     protected static class InMemoryBackend extends Backend {
135         private final String fileName_;
136         private final String type_;
137         private final long lastModified_;
138         private final byte[] bytes_;
139 
140         /**
141          * Ctor.
142          *
143          * @param bytes the bytes
144          * @param fileName the name
145          * @param type the type
146          * @param lastModified last modified
147          */
148         protected InMemoryBackend(final byte[] bytes, final String fileName,
149                 final String type, final long lastModified) {
150             super();
151             fileName_ = fileName;
152             type_ = type;
153             lastModified_ = lastModified;
154             bytes_ = bytes;
155         }
156 
157         /**
158          * Factory method to create an {@link InMemoryBackend} from an {@link NativeArray}.
159          *
160          * @param fileBits the bytes as {@link NativeArray}
161          * @param fileName the name
162          * @param type the type
163          * @param lastModified last modified
164          * @return the new {@link InMemoryBackend}
165          */
166         protected static InMemoryBackend create(final NativeArray fileBits, final String fileName,
167                 final String type, final long lastModified) {
168             if (fileBits == null) {
169                 return new InMemoryBackend(new byte[0], fileName, type, lastModified);
170             }
171 
172             final ByteArrayOutputStream out = new ByteArrayOutputStream();
173             final long length = fileBits.getLength();
174             for (long i = 0; i < length; i++) {
175                 final Object fileBit = fileBits.get(i);
176                 if (fileBit instanceof NativeArrayBuffer buffer) {
177                     final byte[] bytes = buffer.getBuffer();
178                     out.write(bytes, 0, bytes.length);
179                 }
180                 else if (fileBit instanceof NativeArrayBufferView view) {
181                     final byte[] bytes = view.getBuffer().getBuffer();
182                     out.write(bytes, 0, bytes.length);
183                 }
184                 else if (fileBit instanceof Blob blob) {
185                     final byte[] bytes = blob.getBackend().getBytes(0, (int) blob.getSize());
186                     out.write(bytes, 0, bytes.length);
187                 }
188                 else {
189                     final String bits = JavaScriptEngine.toString(fileBits.get(i));
190                     // Todo normalize line breaks
191                     final byte[] bytes = bits.getBytes(UTF_8);
192                     out.write(bytes, 0, bytes.length);
193                 }
194             }
195             return new InMemoryBackend(out.toByteArray(), fileName, type, lastModified);
196         }
197 
198         /**
199          * {@inheritDoc}
200          */
201         @Override
202         public String getName() {
203             return fileName_;
204         }
205 
206         /**
207          * {@inheritDoc}
208          */
209         @Override
210         public long getLastModified() {
211             return lastModified_;
212         }
213 
214         /**
215          * {@inheritDoc}
216          */
217         @Override
218         public long getSize() {
219             return bytes_.length;
220         }
221 
222         /**
223          * {@inheritDoc}
224          */
225         @Override
226         public String getType(final BrowserVersion browserVersion) {
227             return type_.toLowerCase(Locale.ROOT);
228         }
229 
230         /**
231          * {@inheritDoc}
232          */
233         @Override
234         public String getText() throws IOException {
235             return new String(bytes_, UTF_8);
236         }
237 
238         /**
239          * {@inheritDoc}
240          */
241         @Override
242         public byte[] getBytes(final int start, final int end) {
243             final byte[] result = new byte[end - start];
244             System.arraycopy(bytes_, start, result, 0, result.length);
245             return result;
246         }
247 
248         /**
249          * {@inheritDoc}
250          */
251         @Override
252         public KeyDataPair getKeyDataPair(final String name, final String fileName, final String contentType) {
253             String fname = fileName;
254             if (fname == null) {
255                 fname = getName();
256             }
257             final KeyDataPair data = new KeyDataPair(name, null, fname, contentType, (Charset) null);
258             data.setData(bytes_);
259             return data;
260         }
261     }
262 
263     protected static String extractFileTypeOrDefault(final ScriptableObject properties) {
264         if (properties == null || JavaScriptEngine.isUndefined(properties)) {
265             return OPTIONS_TYPE_DEFAULT;
266         }
267 
268         final Object optionsType = properties.get(OPTIONS_TYPE_NAME, properties);
269         if (optionsType != null && properties != Scriptable.NOT_FOUND
270                 && !JavaScriptEngine.isUndefined(optionsType)) {
271             return JavaScriptEngine.toString(optionsType);
272         }
273 
274         return OPTIONS_TYPE_DEFAULT;
275     }
276 
277     protected static long extractLastModifiedOrDefault(final ScriptableObject properties) {
278         if (properties == null || JavaScriptEngine.isUndefined(properties)) {
279             return System.currentTimeMillis();
280         }
281 
282         final Object optionsType = properties.get(OPTIONS_LASTMODIFIED, properties);
283         if (optionsType != null && optionsType != Scriptable.NOT_FOUND
284                 && !JavaScriptEngine.isUndefined(optionsType)) {
285             try {
286                 return Long.parseLong(JavaScriptEngine.toString(optionsType));
287             }
288             catch (final NumberFormatException ignored) {
289                 // fall back to default
290             }
291         }
292 
293         return System.currentTimeMillis();
294     }
295 
296     /**
297      * Creates an instance.
298      */
299     public Blob() {
300         super();
301     }
302 
303     /**
304      * Creates an instance.
305      * @param fileBits the bits
306      * @param properties the properties
307      */
308     @JsxConstructor
309     public void jsConstructor(final NativeArray fileBits, final ScriptableObject properties) {
310         NativeArray nativeBits = fileBits;
311         if (JavaScriptEngine.isUndefined(fileBits)) {
312             nativeBits = null;
313         }
314 
315         backend_ = InMemoryBackend.create(nativeBits, null,
316                             extractFileTypeOrDefault(properties),
317                             extractLastModifiedOrDefault(properties));
318     }
319 
320     /**
321      * Ctor.
322      *
323      * @param bytes the bytes
324      * @param contentType the content type
325      */
326     public Blob(final byte[] bytes, final String contentType) {
327         super();
328         setBackend(new InMemoryBackend(bytes, null, contentType, -1));
329     }
330 
331     /**
332      * Returns the {@code size} property.
333      *
334      * @return the {@code size} property
335      */
336     @JsxGetter
337     public long getSize() {
338         return getBackend().getSize();
339     }
340 
341     /**
342      * Returns the {@code type} property.
343      *
344      * @return the {@code type} property
345      */
346     @JsxGetter
347     public String getType() {
348         return getBackend().getType(getBrowserVersion());
349     }
350 
351     /**
352      * Returns a Promise that resolves with an ArrayBuffer containing the
353      *         data in binary form.
354      *
355      * @return a Promise that resolves with an ArrayBuffer containing the
356      *         data in binary form.
357      */
358     @JsxFunction
359     public NativePromise arrayBuffer() {
360         return setupPromise(() -> {
361             final byte[] bytes = getBytes();
362             final NativeArrayBuffer buffer = new NativeArrayBuffer(bytes.length);
363             System.arraycopy(bytes, 0, buffer.getBuffer(), 0, bytes.length);
364             buffer.setParentScope(getParentScope());
365             buffer.setPrototype(ScriptableObject.getClassPrototype(getParentScope(), buffer.getClassName()));
366             return buffer;
367         });
368     }
369 
370     /**
371      * Returns a new Blob object which contains data from a subset of the blob on which it's called.
372      *
373      * @param start An index into the Blob indicating the first byte to include in the new Blob. If you specify
374      *        a negative value, it's treated as an offset from the end of the Blob toward the beginning.
375      *        For example, -10 would be the 10th from last byte in the Blob. The default value is 0.
376      *        If you specify a value for start that is larger than the size of the source Blob,
377      *        the returned Blob has size 0 and contains no data.
378      * @param end An index into the Blob indicating the first byte that will not be included in the
379      *        new Blob (i.e. the byte exactly at this index is not included). If you specify a negative value,
380      *        it's treated as an offset from the end of the Blob toward the beginning.
381      *        For example, -10 would be the 10th from last byte in the Blob. The default value is size.
382      * @param contentType The content type to assign to the new Blob; this will be the value of its type property. The default value is an empty string.
383      * @return a new Blob object which contains data from a subset of the blob on which it's called.
384      */
385     @JsxFunction
386     public Blob slice(final Object start, final Object end, final Object contentType) {
387         final Blob blob = new Blob();
388         blob.setParentScope(getParentScope());
389         blob.setPrototype(getPrototype(Blob.class));
390 
391         final int size = (int) getSize();
392         int usedStart = 0;
393         int usedEnd = size;
394         if (start != null && !JavaScriptEngine.isUndefined(start)) {
395             usedStart = JavaScriptEngine.toInt32(start);
396             if (usedStart < 0) {
397                 usedStart = size + usedStart;
398             }
399             usedStart = Math.max(0, usedStart);
400         }
401 
402         if (end != null && !JavaScriptEngine.isUndefined(end)) {
403             usedEnd = JavaScriptEngine.toInt32(end);
404             if (usedEnd < 0) {
405                 usedEnd = size + usedEnd;
406             }
407             usedEnd = Math.min(size, usedEnd);
408         }
409 
410         String usedContentType = "";
411         if (contentType != null && !JavaScriptEngine.isUndefined(contentType)) {
412             usedContentType = JavaScriptEngine.toString(contentType).toLowerCase(Locale.ROOT);
413         }
414 
415         if (usedEnd <= usedStart || usedStart >= getSize()) {
416             blob.setBackend(new InMemoryBackend(new byte[0], null, usedContentType, 0L));
417             return blob;
418         }
419 
420         blob.setBackend(new InMemoryBackend(getBackend().getBytes(usedStart, usedEnd), null, usedContentType, 0L));
421         return blob;
422     }
423 
424     /**
425      * Returns a ReadableStream which, upon reading, returns the contents of the Blob.
426      *
427      * @return a ReadableStream which, upon reading, returns the contents of the Blob.
428      */
429     @JsxFunction
430     public ReadableStream stream() {
431         throw new UnsupportedOperationException("Blob.stream() is not yet implemented.");
432     }
433 
434     /**
435      * Returns a Promise that resolves with a string containing the
436      *         contents of the blob, interpreted as UTF-8.
437      *
438      * @return a Promise that resolves with a string containing the
439      *         contents of the blob, interpreted as UTF-8.
440      */
441     @JsxFunction
442     public NativePromise text() {
443         return setupPromise(() -> getBackend().getText());
444     }
445 
446     /**
447      * Returns the bytes of this blob.
448      *
449      * @return the bytes of this blob
450      */
451     public byte[] getBytes() {
452         return getBackend().getBytes(0, (int) getBackend().getSize());
453     }
454 
455     /**
456      * Sets the specified request with the parameters in this {@code FormData}.
457      * @param webRequest the web request to fill
458      */
459     public void fillRequest(final WebRequest webRequest) {
460         webRequest.setRequestBody(new String(getBytes(), UTF_8));
461 
462         final boolean contentTypeDefinedByCaller = webRequest.getAdditionalHeader(HttpHeader.CONTENT_TYPE) != null;
463         if (!contentTypeDefinedByCaller) {
464             final String mimeType = getType();
465             if (StringUtils.isNotBlank(mimeType)) {
466                 webRequest.setAdditionalHeader(HttpHeader.CONTENT_TYPE, mimeType);
467             }
468             webRequest.setEncodingType(null);
469         }
470     }
471 
472     /**
473      * Delegates the KeyDataPair construction to the backend.
474      * @param name the name
475      * @param fileName the filename
476      * @return the constructed {@link KeyDataPair}
477      */
478     public KeyDataPair getKeyDataPair(final String name, final String fileName) {
479         String contentType = getType();
480         if (StringUtils.isEmptyOrNull(contentType)) {
481             contentType = MimeType.APPLICATION_OCTET_STREAM;
482         }
483 
484         return backend_.getKeyDataPair(name, fileName, contentType);
485     }
486 
487     protected Backend getBackend() {
488         return backend_;
489     }
490 
491     protected void setBackend(final Backend backend) {
492         backend_ = backend;
493     }
494 
495 }