Line data Source code
1 369 : /*
2 : * Copyright (C) 2024 Red Hat, Inc.
3 : * SPDX-License-Identifier: GPL-3.0-or-later
4 : */
5 :
6 : /*
7 : * This is the "cockpit.location" API converted to TypeScript and moved out of
8 : * cockpit.js. In the future a new Location API should be designed to replace
9 : * the "cockpit.location" one and become importable from pkg/lib/cockpit as ESM module.
10 : */
11 :
12 : import { url_root, calculate_application } from './location-utils';
13 :
14 : type Options = { [name: string]: string | Array<string> };
15 : type Path = string | string[] | Location;
16 :
17 344 : export class Location {
18 : path: string[];
19 : href: string;
20 : url_root: string;
21 : options: Options;
22 344 : #hash_changed: boolean = false;
23 :
24 344 : constructor() {
25 344 : const application = calculate_application();
26 341 : this.url_root = url_root || "";
27 :
28 64 : if (window.mock?.url_root)
29 64 : this.url_root = window.mock.url_root;
30 :
31 64 : if (application.indexOf("cockpit+=") === 0) {
32 64 : if (this.url_root)
33 64 : this.url_root += '/';
34 64 : this.url_root = this.url_root + application.replace("cockpit+", '');
35 64 : }
36 :
37 344 : this.href = window.location.hash.slice(1);
38 344 : this.options = {};
39 344 : this.path = this.decode(this.href, this.options);
40 344 : }
41 :
42 371 : #resolve_path_dots(parts: string[]): string[] {
43 371 : const out = [];
44 371 : for (let i = 0; i < parts.length; i++) {
45 371 : const part = parts[i];
46 366 : if (part === "" || part == ".") {
47 371 : continue;
48 91 : } else if (part == "..") {
49 91 : if (out.length === 0)
50 91 : return [];
51 91 : out.pop();
52 91 : } else {
53 366 : out.push(part);
54 366 : }
55 371 : }
56 371 : return out;
57 371 : }
58 :
59 155 : #href_for_go_or_replace(path: Path, options?: Options): string {
60 131 : options = options || {};
61 42 : if (typeof path === "string") {
62 42 : return this.encode(this.decode(path, options), options);
63 7 : } else if (path instanceof Location) {
64 7 : return path.href;
65 7 : } else {
66 144 : return this.encode(path, options);
67 144 : }
68 155 : }
69 :
70 371 : #decode_path(input: string): string[] {
71 371 : let result, i;
72 371 : let pre_parts: string[] = [];
73 371 : const parts = input.split('/').map(decodeURIComponent);
74 :
75 371 : if (this.url_root)
76 94 : pre_parts = this.url_root.split('/').map(decodeURIComponent);
77 :
78 94 : if (input && input[0] !== "/" && this.path !== undefined) {
79 94 : result = [...this.path];
80 94 : result.pop();
81 94 : result = result.concat(parts);
82 94 : } else {
83 371 : result = parts;
84 371 : }
85 :
86 371 : result = this.#resolve_path_dots(result);
87 94 : for (i = 0; i < pre_parts.length; i++) {
88 94 : if (pre_parts[i] !== result[i])
89 94 : break;
90 94 : }
91 371 : if (i == pre_parts.length)
92 371 : result.splice(0, pre_parts.length);
93 :
94 371 : return result;
95 371 : }
96 :
97 359 : encode(path: string | string[], options: Options, with_root: boolean = false): string {
98 359 : if (typeof path == "string")
99 84 : path = this.#decode_path(path);
100 :
101 359 : let href = "/" + path.map(encodeURIComponent).join("/");
102 87 : if (with_root && this.url_root && href.indexOf("/" + this.url_root + "/") !== 0)
103 87 : href = "/" + this.url_root + href;
104 :
105 : /* Undo unnecessary encoding of these */
106 359 : href = href.replaceAll("%40", "@");
107 359 : href = href.replaceAll("%3D", "=");
108 359 : href = href.replaceAll("%2B", "+");
109 359 : href = href.replaceAll("%23", "#");
110 :
111 359 : const query: string[] = [];
112 224 : if (options) {
113 105 : for (const opt of Object.keys(options).sort()) {
114 105 : let value = options[opt];
115 105 : if (!Array.isArray(value))
116 105 : value = [value];
117 23 : value.forEach(function(v: string) {
118 23 : query.push(encodeURIComponent(opt) + "=" + encodeURIComponent(v));
119 23 : });
120 105 : }
121 224 : if (query.length > 0)
122 105 : href += "?" + query.join("&");
123 224 : }
124 359 : return href;
125 359 : }
126 :
127 : // NOTE: The options argument is modified in place
128 371 : decode(href: string, options: Options): string[] {
129 371 : if (href[0] == '#')
130 91 : href = href.substring(1);
131 :
132 371 : const pos = href.indexOf('?');
133 115 : const first = (pos === -1) ? href : href.substring(0, pos);
134 371 : const path = this.#decode_path(first);
135 115 : if (pos !== -1 && options) {
136 115 : href.substring(pos + 1).split("&")
137 27 : .forEach(function(opt) {
138 27 : const parts = opt.split('=');
139 27 : const name = decodeURIComponent(parts[0]);
140 27 : const value = decodeURIComponent(parts[1]);
141 2 : if (options[name]) {
142 2 : let last = options[name];
143 2 : if (!Array.isArray(last))
144 2 : last = options[name] = [last];
145 2 : last.push(value);
146 2 : } else {
147 27 : options[name] = value;
148 27 : }
149 27 : });
150 115 : }
151 :
152 371 : return path;
153 371 : }
154 :
155 3 : replace(path: Path, options?: Options) {
156 3 : if (this.#hash_changed)
157 3 : return;
158 3 : const href = this.#href_for_go_or_replace(path, options);
159 3 : window.location.replace(window.location.pathname + '#' + href);
160 3 : }
161 :
162 153 : go(path: Path, options?: Options) {
163 153 : if (this.#hash_changed)
164 153 : return;
165 153 : const hash = '#' + this.#href_for_go_or_replace(path, options);
166 :
167 7 : if (navigator.userAgent.includes("Firefox")) {
168 : // At least Firefox 146 and 147 will (sometimes) trigger a
169 : // full force reload of the page when setting
170 : // window.location.hash. We can work around that by
171 : // clicking on a link.
172 : //
173 : // https://bugzilla.mozilla.org/show_bug.cgi?id=2018546
174 :
175 7 : const a = document.createElement('a');
176 7 : a.href = hash;
177 7 : document.body.appendChild(a);
178 7 : a.click();
179 7 : document.body.removeChild(a);
180 7 : } else {
181 153 : window.location.hash = hash;
182 153 : }
183 153 : }
184 :
185 159 : invalidate() {
186 159 : this.#hash_changed = true;
187 159 : }
188 :
189 0 : toString() {
190 0 : return this.href;
191 0 : }
192 369 : }
|