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;
16  
17  import java.io.IOException;
18  import java.io.ObjectInputStream;
19  import java.io.Serializable;
20  import java.lang.ref.SoftReference;
21  import java.net.URL;
22  import java.util.ArrayList;
23  import java.util.List;
24  
25  import org.htmlunit.javascript.host.Window;
26  import org.htmlunit.javascript.host.event.Event;
27  import org.htmlunit.javascript.host.event.PopStateEvent;
28  import org.htmlunit.util.HeaderUtils;
29  import org.htmlunit.util.UrlUtils;
30  
31  /**
32   * Representation of the navigation history of a single window.
33   *
34   * @author Daniel Gredler
35   * @author Ahmed Ashour
36   * @author Adam Afeltowicz
37   * @author Ronald Brill
38   * @author Madis Pärn
39   */
40  public class History implements Serializable {
41  
42      /** The window to which this navigation history belongs. */
43      private final WebWindow window_;
44  
45      /**
46       * Whether or not to ignore calls to {@link #addPage(Page)}; this is a bit hackish (we should probably be using
47       * explicit boolean parameters in the various methods that load new pages), but it does the job for now -- without
48       * any new API cruft.
49       */
50      private transient ThreadLocal<Boolean> ignoreNewPages_;
51  
52      /**
53       * The {@link History.HistoryEntry}s in this navigation history.
54       */
55      private final List<HistoryEntry> entries_ = new ArrayList<>();
56  
57      /** The current index within the list of pages which make up this navigation history. */
58      private int index_ = -1;
59  
60      /**
61       * The single entry in the history.
62       */
63      private static final class HistoryEntry implements Serializable {
64          private transient SoftReference<Page> page_;
65          private final WebRequest webRequest_;
66          private Object state_;
67  
68          HistoryEntry(final Page page) {
69  
70              // verify cache-control header values before storing
71              if (HeaderUtils.containsNoStore(page.getWebResponse())) {
72                  page_ = null;
73              }
74              else {
75                  page_ = new SoftReference<>(page);
76              }
77  
78              final WebRequest request = page.getWebResponse().getWebRequest();
79              webRequest_ = new WebRequest(request.getUrl(), request.getHttpMethod());
80              webRequest_.setRequestParameters(request.getRequestParameters());
81          }
82  
83          Page getPage() {
84              if (page_ == null) {
85                  return null;
86              }
87              return page_.get();
88          }
89  
90          void clearPage() {
91              page_ = null;
92          }
93  
94          WebRequest getWebRequest() {
95              return webRequest_;
96          }
97  
98          URL getUrl() {
99              return webRequest_.getUrl();
100         }
101 
102         void setUrl(final URL url, final Page page) {
103             if (url != null) {
104                 WebWindow webWindow = null;
105                 if (page != null) {
106                     webWindow = page.getEnclosingWindow();
107                 }
108 
109                 final URL encoded = UrlUtils.encodeUrl(url, webRequest_.getCharset());
110                 webRequest_.setUrl(encoded);
111                 if (page != null) {
112                     page.getWebResponse().getWebRequest().setUrl(encoded);
113                     if (webWindow != null) {
114                         final Window win = webWindow.getScriptableObject();
115                         if (win != null) {
116                             win.getLocation().setHash(null, encoded.getRef(), false);
117                         }
118                     }
119                 }
120             }
121         }
122 
123         /**
124          * @return the state object
125          */
126         Object getState() {
127             return state_;
128         }
129 
130         /**
131          * Sets the state object.
132          * @param state the state object to use
133          */
134         void setState(final Object state) {
135             state_ = state;
136         }
137     }
138 
139     /**
140      * Creates a new navigation history for the specified window.
141      * @param window the window which owns the new navigation history
142      */
143     public History(final WebWindow window) {
144         window_ = window;
145         initTransientFields();
146     }
147 
148     /**
149      * Initializes the transient fields.
150      */
151     private void initTransientFields() {
152         ignoreNewPages_ = new ThreadLocal<>();
153     }
154 
155     /**
156      * Returns the length of the navigation history.
157      * @return the length of the navigation history
158      */
159     public int getLength() {
160         return entries_.size();
161     }
162 
163     /**
164      * Returns the current (zero-based) index within the navigation history.
165      * @return the current (zero-based) index within the navigation history
166      */
167     public int getIndex() {
168         return index_;
169     }
170 
171     /**
172      * Returns the URL at the specified index in the navigation history, or {@code null} if the index is not valid.
173      * @param index the index of the URL to be returned
174      * @return the URL at the specified index in the navigation history, or {@code null} if the index is not valid
175      */
176     public URL getUrl(final int index) {
177         if (index >= 0 && index < entries_.size()) {
178             return UrlUtils.toUrlSafe(entries_.get(index).getUrl().toExternalForm());
179         }
180         return null;
181     }
182 
183     /**
184      * Goes back one step in the navigation history, if possible.
185      * @return this navigation history, after going back one step
186      * @throws IOException in case of error
187      */
188     public History back() throws IOException {
189         if (index_ > 0) {
190             index_--;
191             goToUrlAtCurrentIndex();
192         }
193         return this;
194     }
195 
196     /**
197      * Goes forward one step in the navigation history, if possible.
198      * @return this navigation history, after going forward one step
199      * @throws IOException in case of error
200      */
201     public History forward() throws IOException {
202         if (index_ < entries_.size() - 1) {
203             index_++;
204             goToUrlAtCurrentIndex();
205         }
206         return this;
207     }
208 
209     /**
210      * Goes forward or backwards in the navigation history, according to whether the specified relative index
211      * is positive or negative. If the specified index is <code>0</code>, this method reloads the current page.
212      * @param relativeIndex the index to move to, relative to the current index
213      * @return this navigation history, after going forwards or backwards the specified number of steps
214      * @throws IOException in case of error
215      */
216     public History go(final int relativeIndex) throws IOException {
217         final int i = index_ + relativeIndex;
218         if (i < entries_.size() && i >= 0) {
219             index_ = i;
220             goToUrlAtCurrentIndex();
221         }
222         return this;
223     }
224 
225     /**
226      * {@inheritDoc}
227      */
228     @Override
229     public String toString() {
230         return entries_.toString();
231     }
232 
233     /**
234      * Removes the current URL from the history.
235      */
236     public void removeCurrent() {
237         if (index_ >= 0 && index_ < entries_.size()) {
238             entries_.remove(index_);
239             if (index_ > 0) {
240                 index_--;
241             }
242         }
243     }
244 
245     /**
246      * Adds a new page to the navigation history.
247      * @param page the page to add to the navigation history
248      * @return the created history entry
249      */
250     protected HistoryEntry addPage(final Page page) {
251         final Boolean ignoreNewPages = ignoreNewPages_.get();
252         if (ignoreNewPages != null && ignoreNewPages.booleanValue()) {
253             return null;
254         }
255 
256         final int sizeLimit = window_.getWebClient().getOptions().getHistorySizeLimit();
257         if (sizeLimit <= 0) {
258             entries_.clear();
259             index_ = -1;
260             return null;
261         }
262 
263         index_++;
264         while (entries_.size() > index_) {
265             entries_.remove(index_);
266         }
267         while (entries_.size() >= sizeLimit) {
268             entries_.remove(0);
269             index_--;
270         }
271 
272         final HistoryEntry entry = new HistoryEntry(page);
273         entries_.add(entry);
274 
275         final int cacheLimit = Math.max(window_.getWebClient().getOptions().getHistoryPageCacheLimit(), 0);
276         if (entries_.size() > cacheLimit) {
277             entries_.get(entries_.size() - cacheLimit - 1).clearPage();
278         }
279 
280         return entry;
281     }
282 
283     /**
284      * Loads the URL at the current index into the window to which this navigation history belongs.
285      * @throws IOException if an IO error occurs
286      */
287     private void goToUrlAtCurrentIndex() throws IOException {
288         final Boolean old = ignoreNewPages_.get();
289         ignoreNewPages_.set(Boolean.TRUE);
290         try {
291 
292             final HistoryEntry entry = entries_.get(index_);
293 
294             final Page page = entry.getPage();
295             if (page == null) {
296                 window_.getWebClient().getPage(window_, entry.getWebRequest(), false);
297             }
298             else {
299                 window_.setEnclosedPage(page);
300                 page.getWebResponse().getWebRequest().setUrl(entry.getUrl());
301             }
302 
303             final Window jsWindow = window_.getScriptableObject();
304             if (jsWindow != null && jsWindow.hasEventHandlers("onpopstate")) {
305                 final Event event = new PopStateEvent(jsWindow, Event.TYPE_POPSTATE, entry.getState());
306                 jsWindow.executeEventLocally(event);
307             }
308         }
309         finally {
310             ignoreNewPages_.set(old);
311         }
312     }
313 
314     /**
315      * Allows to change history state and url if provided.
316      *
317      * @param state the new state to use
318      * @param url the new url to use
319      */
320     public void replaceState(final Object state, final URL url) {
321         if (index_ >= 0 && index_ < entries_.size()) {
322             final HistoryEntry entry = entries_.get(index_);
323 
324             Page page = entry.getPage();
325             if (page == null) {
326                 page = window_.getEnclosedPage();
327             }
328 
329             entry.setUrl(url, page);
330             entry.setState(state);
331         }
332     }
333 
334     /**
335      * Allows to change history state and url if provided.
336      *
337      * @param state the new state to use
338      * @param url the new url to use
339      */
340     public void pushState(final Object state, final URL url) {
341         final Page page = window_.getEnclosedPage();
342         final HistoryEntry entry = addPage(page);
343 
344         if (entry != null) {
345             entry.setUrl(url, page);
346             entry.setState(state);
347         }
348     }
349 
350     /**
351      * Returns current state object.
352      *
353      * @return the current state object
354      */
355     public Object getCurrentState() {
356         if (index_ >= 0 && index_ < entries_.size()) {
357             return entries_.get(index_).getState();
358         }
359         return null;
360     }
361 
362     /**
363      * Re-initializes transient fields when an object of this type is deserialized.
364      * @param in the object input stream
365      * @throws IOException if an error occurs
366      * @throws ClassNotFoundException if an error occurs
367      */
368     private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
369         in.defaultReadObject();
370         initTransientFields();
371     }
372 }