View Javadoc
1   /*
2    * Copyright (c) 2002-2025 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.util;
16  
17  import static java.nio.charset.StandardCharsets.ISO_8859_1;
18  
19  import java.io.BufferedWriter;
20  import java.io.EOFException;
21  import java.io.File;
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.io.OutputStream;
25  import java.net.URL;
26  import java.nio.charset.StandardCharsets;
27  import java.nio.file.Files;
28  import java.nio.file.StandardOpenOption;
29  import java.util.ArrayList;
30  import java.util.List;
31  import java.util.regex.Pattern;
32  
33  import org.apache.commons.io.FileUtils;
34  import org.apache.commons.io.IOUtils;
35  import org.apache.commons.lang3.StringUtils;
36  import org.apache.commons.logging.Log;
37  import org.apache.commons.logging.LogFactory;
38  import org.htmlunit.FormEncodingType;
39  import org.htmlunit.HttpMethod;
40  import org.htmlunit.WebConnection;
41  import org.htmlunit.WebRequest;
42  import org.htmlunit.WebResponse;
43  import org.htmlunit.WebResponseData;
44  import org.htmlunit.javascript.JavaScriptEngine;
45  
46  /**
47   * Wrapper around a "real" WebConnection that will use the wrapped web connection
48   * to do the real job and save all received responses
49   * in the temp directory with an overview page.<br>
50   * <br>
51   * This may be useful at conception time to understand what is "browsed".<br>
52   * <br>
53   * Example:
54   * <pre>
55   * final WebClient client = new WebClient();
56   * final WebConnection connection = new DebuggingWebConnection(client.getWebConnection(), "myTest");
57   * client.setWebConnection(connection);
58   * </pre>
59   * In this example an overview page will be generated under the name myTest/index.html in the temp directory
60   * and all received responses will be saved into the myTest folder.<br>
61   * <br>
62   * <em>This class is only intended as a help during the conception.</em>
63   *
64   * @author Marc Guillemot
65   * @author Ahmed Ashour
66   * @author Ronald Brill
67   */
68  public class DebuggingWebConnection extends WebConnectionWrapper {
69      private static final Log LOG = LogFactory.getLog(DebuggingWebConnection.class);
70  
71      private static final Pattern ESCAPE_QUOTE_PATTERN = Pattern.compile("'");
72  
73      private int counter_;
74      private final WebConnection wrappedWebConnection_;
75      private final File javaScriptFile_;
76      private final File reportFolder_;
77      private boolean uncompressJavaScript_ = true;
78  
79      /**
80       * Wraps a web connection to have a report generated of the received responses.
81       * @param webConnection the webConnection that do the real work
82       * @param dirName the name of the directory to create in the tmp folder to save received responses.
83       *        If this folder already exists, it will be deleted first.
84       * @throws IOException in case of problems writing the files
85       */
86      public DebuggingWebConnection(final WebConnection webConnection,
87              final String dirName) throws IOException {
88  
89          super(webConnection);
90  
91          wrappedWebConnection_ = webConnection;
92          final File tmpDir = new File(System.getProperty("java.io.tmpdir"));
93          reportFolder_ = new File(tmpDir, dirName);
94          if (reportFolder_.exists()) {
95              FileUtils.forceDelete(reportFolder_);
96          }
97          FileUtils.forceMkdir(reportFolder_);
98          javaScriptFile_ = new File(reportFolder_, "hu.js");
99          createOverview();
100     }
101 
102     /**
103      * Calls the wrapped webconnection and save the received response.
104      * {@inheritDoc}
105      */
106     @Override
107     public WebResponse getResponse(final WebRequest request) throws IOException {
108         WebResponse response = wrappedWebConnection_.getResponse(request);
109         if (isUncompressJavaScript() && isJavaScript(response.getContentType())) {
110             response = uncompressJavaScript(response);
111         }
112         saveResponse(response, request);
113         return response;
114     }
115 
116     /**
117      * Tries to uncompress the JavaScript code in the provided response.
118      * @param response the response to uncompress
119      * @return a new response with uncompressed JavaScript code or the original response in case of failure
120      */
121     protected WebResponse uncompressJavaScript(final WebResponse response) {
122         final WebRequest request = response.getWebRequest();
123         final String scriptName = request.getUrl().toString();
124         final String scriptSource = response.getContentAsString();
125 
126         // skip if it is already formatted? => TODO
127 
128         try {
129             final String decompileScript = JavaScriptEngine.uncompressJavaScript(scriptSource, scriptName);
130 
131             final List<NameValuePair> responseHeaders = new ArrayList<>(response.getResponseHeaders());
132             for (int i = responseHeaders.size() - 1; i >= 0; i--) {
133                 if ("content-encoding".equalsIgnoreCase(responseHeaders.get(i).getName())) {
134                     responseHeaders.remove(i);
135                 }
136             }
137             final WebResponseData wrd = new WebResponseData(decompileScript.getBytes(), response.getStatusCode(),
138                 response.getStatusMessage(), responseHeaders);
139             return new WebResponse(wrd, response.getWebRequest().getUrl(),
140                 response.getWebRequest().getHttpMethod(), response.getLoadTime());
141         }
142         catch (final Exception e) {
143             LOG.warn("Failed to decompress JavaScript response. Delivering as it.", e);
144         }
145 
146         return response;
147     }
148 
149     /**
150      * Adds a mark that will be visible in the HTML result page generated by this class.
151      * @param mark the text
152      * @throws IOException if a problem occurs writing the file
153      */
154     public void addMark(String mark) throws IOException {
155         if (mark != null) {
156             mark = mark.replace("\"", "\\\"");
157         }
158         appendToJSFile("tab[tab.length] = \"" + mark + "\";\n");
159         if (LOG.isInfoEnabled()) {
160             LOG.info("--- " + mark + " ---");
161         }
162     }
163 
164     /**
165      * Saves the response content in the temp dir and adds it to the summary page.
166      * @param response the response to save
167      * @param request the request used to get the response
168      * @throws IOException if a problem occurs writing the file
169      */
170     protected void saveResponse(final WebResponse response, final WebRequest request)
171         throws IOException {
172         counter_++;
173         final String extension = chooseExtension(response.getContentType());
174         final File file = createFile(request.getUrl(), extension);
175         int length = 0;
176         try (InputStream input = response.getContentAsStream()) {
177             try (OutputStream fos = Files.newOutputStream(file.toPath())) {
178                 length = IOUtils.copy(input, fos);
179             }
180             catch (final EOFException ignored) {
181                 // ignore
182             }
183         }
184 
185         final URL url = response.getWebRequest().getUrl();
186         if (LOG.isInfoEnabled()) {
187             LOG.info("Created file " + file.getAbsolutePath() + " for response " + counter_ + ": " + url);
188         }
189 
190         final StringBuilder bduiler = new StringBuilder();
191         bduiler.append("tab[tab.length] = {code: ").append(response.getStatusCode())
192                 .append(", fileName: '").append(file.getName()).append("', ")
193                 .append("contentType: '").append(response.getContentType())
194                 .append("', method: '").append(request.getHttpMethod().name()).append("', ");
195         if (request.getHttpMethod() == HttpMethod.POST && request.getEncodingType() == FormEncodingType.URL_ENCODED) {
196             bduiler.append("postParameters: ").append(nameValueListToJsMap(request.getRequestParameters()))
197                 .append(", ");
198         }
199         bduiler.append("url: '").append(escapeJSString(url.toString()))
200                 .append("', loadTime: ").append(response.getLoadTime())
201                 .append(", responseSize: ").append(length)
202                 .append(", responseHeaders: ").append(nameValueListToJsMap(response.getResponseHeaders()))
203                 .append("};\n");
204         appendToJSFile(bduiler.toString());
205     }
206 
207     static String escapeJSString(final String string) {
208         return ESCAPE_QUOTE_PATTERN.matcher(string).replaceAll("\\\\'");
209     }
210 
211     static String chooseExtension(final String contentType) {
212         if (isJavaScript(contentType)) {
213             return ".js";
214         }
215         else if (MimeType.TEXT_HTML.equals(contentType)) {
216             return ".html";
217         }
218         else if (MimeType.TEXT_CSS.equals(contentType)) {
219             return ".css";
220         }
221         else if (MimeType.TEXT_XML.equals(contentType)) {
222             return ".xml";
223         }
224         else if (MimeType.IMAGE_GIF.equals(contentType)) {
225             return ".gif";
226         }
227         return ".txt";
228     }
229 
230     /**
231      * Indicates if the response contains JavaScript content.
232      * @param contentType the response's content type
233      * @return {@code false} if it is not recognized as JavaScript
234      */
235     static boolean isJavaScript(final String contentType) {
236         return contentType.contains("javascript") || contentType.contains("ecmascript")
237             || (contentType.startsWith("text/") && contentType.endsWith("js"));
238     }
239 
240     /**
241      * Indicates if it should try to format responses recognized as JavaScript.
242      * @return default is {@code false} to deliver the original content
243      */
244     public boolean isUncompressJavaScript() {
245         return uncompressJavaScript_;
246     }
247 
248     /**
249      * Indicates that responses recognized as JavaScript should be formatted or not.
250      * Formatting is interesting for debugging when the original script is compressed on a single line.
251      * It allows to better follow with a debugger and to obtain more interesting error messages.
252      * @param decompress {@code true} if JavaScript responses should be uncompressed
253      */
254     public void setUncompressJavaScript(final boolean decompress) {
255         uncompressJavaScript_ = decompress;
256     }
257 
258     private void appendToJSFile(final String str) throws IOException {
259         try (BufferedWriter jsFileWriter = Files.newBufferedWriter(javaScriptFile_.toPath(),
260                                                     StandardCharsets.UTF_8, StandardOpenOption.APPEND)) {
261             jsFileWriter.write(str);
262         }
263     }
264 
265     /**
266      * Computes the best file to save the response to the given URL.
267      * @param url the requested URL
268      * @param extension the preferred extension
269      * @return the file to create
270      * @throws IOException if a problem occurs creating the file
271      */
272     private File createFile(final URL url, final String extension) throws IOException {
273         String name = url.getPath().replaceFirst("/$", "").replaceAll(".*/", "");
274         name = StringUtils.substringBefore(name, "?"); // remove query
275         name = StringUtils.substringBefore(name, ";"); // remove additional info
276         name = StringUtils.substring(name, 0, 30); // avoid exceptions due to too long file names
277         name = org.htmlunit.util.StringUtils.sanitizeForFileName(name);
278         if (!name.endsWith(extension)) {
279             name += extension;
280         }
281         int counter = 0;
282         while (true) {
283             final String fileName;
284             if (counter != 0) {
285                 fileName = StringUtils.substringBeforeLast(name, ".")
286                     + "_" + counter + "." + StringUtils.substringAfterLast(name, ".");
287             }
288             else {
289                 fileName = name;
290             }
291             final File f = new File(reportFolder_, fileName);
292             if (f.createNewFile()) {
293                 return f;
294             }
295             counter++;
296         }
297     }
298 
299     /**
300      * Produces a String that will produce a JS map like "{'key1': 'value1', 'key 2': 'value2'}".
301      * @param headers a list of {@link NameValuePair}
302      * @return the JS String
303      */
304     static String nameValueListToJsMap(final List<NameValuePair> headers) {
305         if (headers == null || headers.isEmpty()) {
306             return "{}";
307         }
308         final StringBuilder bduiler = new StringBuilder("{");
309         for (final NameValuePair header : headers) {
310             bduiler.append('\'').append(header.getName()).append("': '")
311                     .append(escapeJSString(header.getValue())).append("', ");
312         }
313         bduiler.delete(bduiler.length() - 2, bduiler.length());
314         bduiler.append('}');
315         return bduiler.toString();
316     }
317 
318     /**
319      * Creates the summary file and the JavaScript file that will be updated for each received response
320      * @throws IOException if a problem occurs writing the file
321      */
322     private void createOverview() throws IOException {
323         FileUtils.writeStringToFile(javaScriptFile_, "var tab = [];\n", ISO_8859_1);
324 
325         final URL indexResource = DebuggingWebConnection.class.getResource("DebuggingWebConnection.index.html");
326         if (indexResource == null) {
327             throw new RuntimeException("Missing dependency DebuggingWebConnection.index.html");
328         }
329         final File summary = new File(reportFolder_, "index.html");
330         FileUtils.copyURLToFile(indexResource, summary);
331 
332         if (LOG.isInfoEnabled()) {
333             LOG.info("Summary will be in " + summary.getAbsolutePath());
334         }
335     }
336 
337     File getReportFolder() {
338         return reportFolder_;
339     }
340 }