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