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 }