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