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;
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 * Returns the state object.
125 *
126 * @return the state object
127 */
128 Object getState() {
129 return state_;
130 }
131
132 /**
133 * Sets the state object.
134 * @param state the state object to use
135 */
136 void setState(final Object state) {
137 state_ = state;
138 }
139 }
140
141 /**
142 * Creates a new navigation history for the specified window.
143 * @param window the window which owns the new navigation history
144 */
145 public History(final WebWindow window) {
146 window_ = window;
147 initTransientFields();
148 }
149
150 /**
151 * Initializes the transient fields.
152 */
153 private void initTransientFields() {
154 ignoreNewPages_ = new ThreadLocal<>();
155 }
156
157 /**
158 * Returns the length of the navigation history.
159 * @return the length of the navigation history
160 */
161 public int getLength() {
162 return entries_.size();
163 }
164
165 /**
166 * Returns the current (zero-based) index within the navigation history.
167 * @return the current (zero-based) index within the navigation history
168 */
169 public int getIndex() {
170 return index_;
171 }
172
173 /**
174 * Returns the URL at the specified index in the navigation history, or {@code null} if the index is not valid.
175 * @param index the index of the URL to be returned
176 * @return the URL at the specified index in the navigation history, or {@code null} if the index is not valid
177 */
178 public URL getUrl(final int index) {
179 if (index >= 0 && index < entries_.size()) {
180 return UrlUtils.toUrlSafe(entries_.get(index).getUrl().toExternalForm());
181 }
182 return null;
183 }
184
185 /**
186 * Goes back one step in the navigation history, if possible.
187 * @return this navigation history, after going back one step
188 * @throws IOException in case of error
189 */
190 public History back() throws IOException {
191 if (index_ > 0) {
192 index_--;
193 goToUrlAtCurrentIndex();
194 }
195 return this;
196 }
197
198 /**
199 * Goes forward one step in the navigation history, if possible.
200 * @return this navigation history, after going forward one step
201 * @throws IOException in case of error
202 */
203 public History forward() throws IOException {
204 if (index_ < entries_.size() - 1) {
205 index_++;
206 goToUrlAtCurrentIndex();
207 }
208 return this;
209 }
210
211 /**
212 * Goes forward or backwards in the navigation history, according to whether the specified relative index
213 * is positive or negative. If the specified index is <code>0</code>, this method reloads the current page.
214 * @param relativeIndex the index to move to, relative to the current index
215 * @return this navigation history, after going forwards or backwards the specified number of steps
216 * @throws IOException in case of error
217 */
218 public History go(final int relativeIndex) throws IOException {
219 final int i = index_ + relativeIndex;
220 if (i < entries_.size() && i >= 0) {
221 index_ = i;
222 goToUrlAtCurrentIndex();
223 }
224 return this;
225 }
226
227 /**
228 * {@inheritDoc}
229 */
230 @Override
231 public String toString() {
232 return entries_.toString();
233 }
234
235 /**
236 * Removes the current URL from the history.
237 */
238 public void removeCurrent() {
239 if (index_ >= 0 && index_ < entries_.size()) {
240 entries_.remove(index_);
241 if (index_ > 0) {
242 index_--;
243 }
244 }
245 }
246
247 /**
248 * Adds a new page to the navigation history.
249 * @param page the page to add to the navigation history
250 * @return the created history entry
251 */
252 protected HistoryEntry addPage(final Page page) {
253 final Boolean ignoreNewPages = ignoreNewPages_.get();
254 if (ignoreNewPages != null && ignoreNewPages.booleanValue()) {
255 return null;
256 }
257
258 final int sizeLimit = window_.getWebClient().getOptions().getHistorySizeLimit();
259 if (sizeLimit <= 0) {
260 entries_.clear();
261 index_ = -1;
262 return null;
263 }
264
265 index_++;
266 while (entries_.size() > index_) {
267 entries_.remove(index_);
268 }
269 while (entries_.size() >= sizeLimit) {
270 entries_.remove(0);
271 index_--;
272 }
273
274 final HistoryEntry entry = new HistoryEntry(page);
275 entries_.add(entry);
276
277 final int cacheLimit = Math.max(window_.getWebClient().getOptions().getHistoryPageCacheLimit(), 0);
278 if (entries_.size() > cacheLimit) {
279 entries_.get(entries_.size() - cacheLimit - 1).clearPage();
280 }
281
282 return entry;
283 }
284
285 /**
286 * Loads the URL at the current index into the window to which this navigation history belongs.
287 * @throws IOException if an IO error occurs
288 */
289 private void goToUrlAtCurrentIndex() throws IOException {
290 final Boolean old = ignoreNewPages_.get();
291 ignoreNewPages_.set(Boolean.TRUE);
292 try {
293
294 final HistoryEntry entry = entries_.get(index_);
295
296 final Page page = entry.getPage();
297 if (page == null) {
298 window_.getWebClient().getPage(window_, entry.getWebRequest(), false);
299 }
300 else {
301 window_.setEnclosedPage(page);
302 page.getWebResponse().getWebRequest().setUrl(entry.getUrl());
303 }
304
305 final Window jsWindow = window_.getScriptableObject();
306 if (jsWindow != null && jsWindow.hasEventHandlers("onpopstate")) {
307 final Event event = new PopStateEvent(jsWindow, Event.TYPE_POPSTATE, entry.getState());
308 jsWindow.executeEventLocally(event);
309 }
310 }
311 finally {
312 ignoreNewPages_.set(old);
313 }
314 }
315
316 /**
317 * Allows to change history state and url if provided.
318 *
319 * @param state the new state to use
320 * @param url the new url to use
321 */
322 public void replaceState(final Object state, final URL url) {
323 if (index_ >= 0 && index_ < entries_.size()) {
324 final HistoryEntry entry = entries_.get(index_);
325
326 Page page = entry.getPage();
327 if (page == null) {
328 page = window_.getEnclosedPage();
329 }
330
331 entry.setUrl(url, page);
332 entry.setState(state);
333 }
334 }
335
336 /**
337 * Allows to change history state and url if provided.
338 *
339 * @param state the new state to use
340 * @param url the new url to use
341 */
342 public void pushState(final Object state, final URL url) {
343 final Page page = window_.getEnclosedPage();
344 final HistoryEntry entry = addPage(page);
345
346 if (entry != null) {
347 entry.setUrl(url, page);
348 entry.setState(state);
349 }
350 }
351
352 /**
353 * Returns current state object.
354 *
355 * @return the current state object
356 */
357 public Object getCurrentState() {
358 if (index_ >= 0 && index_ < entries_.size()) {
359 return entries_.get(index_).getState();
360 }
361 return null;
362 }
363
364 /**
365 * Re-initializes transient fields when an object of this type is deserialized.
366 * @param in the object input stream
367 * @throws IOException if an error occurs
368 * @throws ClassNotFoundException if an error occurs
369 */
370 private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
371 in.defaultReadObject();
372 initTransientFields();
373 }
374 }