aboutRulesets.js (11624B)
1 "use strict"; 2 3 const Orders = Object.freeze({ 4 Name: "name", 5 NameDesc: "name-desc", 6 LastUpdate: "last-update", 7 }); 8 9 const States = Object.freeze({ 10 Warning: "warning", 11 Details: "details", 12 Edit: "edit", 13 NoRulesets: "noRulesets", 14 }); 15 16 function setUpdateDate(ruleset, element) { 17 if (!ruleset.enabled) { 18 document.l10n.setAttributes(element, "rulesets-update-rule-disabled"); 19 return; 20 } 21 if (!ruleset.currentTimestamp) { 22 document.l10n.setAttributes(element, "rulesets-update-never"); 23 return; 24 } 25 26 document.l10n.setAttributes(element, "rulesets-update-last", { 27 date: ruleset.currentTimestamp * 1000, 28 }); 29 } 30 31 // UI states 32 33 /** 34 * This is the initial warning shown when the user opens about:rulesets. 35 */ 36 class WarningState { 37 elements = { 38 enableCheckbox: document.getElementById("warning-enable-checkbox"), 39 button: document.getElementById("warning-button"), 40 }; 41 42 constructor() { 43 this.elements.enableCheckbox.addEventListener( 44 "change", 45 this.onEnableChange.bind(this) 46 ); 47 48 this.elements.button.addEventListener( 49 "click", 50 this.onButtonClick.bind(this) 51 ); 52 } 53 54 show() { 55 this.elements.button.focus(); 56 } 57 58 hide() {} 59 60 onEnableChange() { 61 RPMSendAsyncMessage( 62 "rulesets:set-show-warning", 63 this.elements.enableCheckbox.checked 64 ); 65 } 66 67 onButtonClick() { 68 gAboutRulesets.selectFirst(); 69 } 70 } 71 72 /** 73 * State shown when the user clicks on a channel to see its details. 74 */ 75 class DetailsState { 76 elements = { 77 title: document.getElementById("ruleset-title"), 78 jwkValue: document.getElementById("ruleset-jwk-value"), 79 pathPrefixValue: document.getElementById("ruleset-path-prefix-value"), 80 scopeValue: document.getElementById("ruleset-scope-value"), 81 enableCheckbox: document.getElementById("ruleset-enable-checkbox"), 82 updateButton: document.getElementById("ruleset-update-button"), 83 updated: document.getElementById("ruleset-updated"), 84 }; 85 86 constructor() { 87 document 88 .getElementById("ruleset-edit") 89 .addEventListener("click", this.onEdit.bind(this)); 90 this.elements.enableCheckbox.addEventListener( 91 "change", 92 this.onEnable.bind(this) 93 ); 94 this.elements.updateButton.addEventListener( 95 "click", 96 this.onUpdate.bind(this) 97 ); 98 } 99 100 show(ruleset) { 101 const elements = this.elements; 102 elements.title.textContent = ruleset.name; 103 elements.jwkValue.textContent = JSON.stringify(ruleset.jwk); 104 elements.pathPrefixValue.setAttribute("href", ruleset.pathPrefix); 105 elements.pathPrefixValue.textContent = ruleset.pathPrefix; 106 elements.scopeValue.textContent = ruleset.scope; 107 elements.enableCheckbox.checked = ruleset.enabled; 108 if (ruleset.enabled) { 109 elements.updateButton.removeAttribute("disabled"); 110 } else { 111 elements.updateButton.setAttribute("disabled", "disabled"); 112 } 113 setUpdateDate(ruleset, elements.updated); 114 this._showing = ruleset; 115 116 gAboutRulesets.list.setItemSelected(ruleset.name); 117 } 118 119 hide() { 120 this._showing = null; 121 } 122 123 onEdit() { 124 gAboutRulesets.setState(States.Edit, this._showing); 125 } 126 127 async onEnable() { 128 await RPMSendAsyncMessage("rulesets:enable-channel", { 129 name: this._showing.name, 130 enabled: this.elements.enableCheckbox.checked, 131 }); 132 } 133 134 async onUpdate() { 135 try { 136 await RPMSendQuery("rulesets:update-channel", this._showing.name); 137 } catch (err) { 138 console.error("Could not update the rulesets", err); 139 } 140 } 141 } 142 143 /** 144 * State to edit a channel. 145 */ 146 class EditState { 147 elements = { 148 form: document.getElementById("edit-ruleset-form"), 149 title: document.getElementById("edit-title"), 150 jwkTextarea: document.getElementById("edit-jwk-textarea"), 151 pathPrefixInput: document.getElementById("edit-path-prefix-input"), 152 scopeInput: document.getElementById("edit-scope-input"), 153 enableCheckbox: document.getElementById("edit-enable-checkbox"), 154 }; 155 156 constructor() { 157 document 158 .getElementById("edit-save") 159 .addEventListener("click", this.onSave.bind(this)); 160 document 161 .getElementById("edit-cancel") 162 .addEventListener("click", this.onCancel.bind(this)); 163 } 164 165 show(ruleset) { 166 const elements = this.elements; 167 elements.form.reset(); 168 elements.title.textContent = ruleset.name; 169 elements.jwkTextarea.value = JSON.stringify(ruleset.jwk); 170 elements.pathPrefixInput.value = ruleset.pathPrefix; 171 elements.scopeInput.value = ruleset.scope; 172 elements.enableCheckbox.checked = ruleset.enabled; 173 this._editing = ruleset; 174 } 175 176 hide() { 177 this.elements.form.reset(); 178 this._editing = null; 179 } 180 181 async onSave(e) { 182 e.preventDefault(); 183 const elements = this.elements; 184 185 let valid = true; 186 const name = this._editing.name; 187 188 let jwk; 189 try { 190 jwk = JSON.parse(elements.jwkTextarea.value); 191 await crypto.subtle.importKey( 192 "jwk", 193 jwk, 194 { 195 name: "RSA-PSS", 196 saltLength: 32, 197 hash: { name: "SHA-256" }, 198 }, 199 true, 200 ["verify"] 201 ); 202 elements.jwkTextarea.setCustomValidity(""); 203 } catch (err) { 204 console.error("Invalid JSON or invalid JWK", err); 205 elements.jwkTextarea.setCustomValidity( 206 await document.l10n.formatValue("rulesets-details-jwk-input-invalid") 207 ); 208 valid = false; 209 } 210 211 const pathPrefix = elements.pathPrefixInput.value.trim(); 212 try { 213 const url = URL.parse(pathPrefix); 214 if (url?.protocol !== "http:" && url?.protocol !== "https:") { 215 elements.pathPrefixInput.setCustomValidity( 216 await document.l10n.formatValue("rulesets-details-path-input-invalid") 217 ); 218 valid = false; 219 } else { 220 elements.pathPrefixInput.setCustomValidity(""); 221 } 222 } catch (err) { 223 console.error("The path prefix is not a valid URL", err); 224 elements.pathPrefixInput.setCustomValidity( 225 await document.l10n.formatValue("rulesets-details-path-input-invalid") 226 ); 227 valid = false; 228 } 229 230 let scope; 231 try { 232 scope = new RegExp(elements.scopeInput.value.trim()); 233 elements.scopeInput.setCustomValidity(""); 234 } catch (err) { 235 elements.scopeInput.setCustomValidity( 236 await document.l10n.formatValue("rulesets-details-scope-input-invalid") 237 ); 238 valid = false; 239 } 240 241 if (!valid) { 242 return; 243 } 244 245 const enabled = elements.enableCheckbox.checked; 246 247 const rulesetData = { name, jwk, pathPrefix, scope, enabled }; 248 const ruleset = await RPMSendQuery("rulesets:set-channel", rulesetData); 249 gAboutRulesets.setState(States.Details, ruleset); 250 if (enabled) { 251 try { 252 await RPMSendQuery("rulesets:update-channel", name); 253 } catch (err) { 254 console.warn("Could not update the ruleset after adding it", err); 255 } 256 } 257 } 258 259 onCancel(e) { 260 e.preventDefault(); 261 if (this._editing === null) { 262 gAboutRulesets.selectFirst(); 263 } else { 264 gAboutRulesets.setState(States.Details, this._editing); 265 } 266 } 267 } 268 269 /** 270 * State shown when no rulesets are available. 271 * Currently, the only way to reach it is to delete all the channels manually. 272 */ 273 class NoRulesetsState { 274 show() {} 275 hide() {} 276 } 277 278 /** 279 * Manages the sidebar with the list of the various channels, and keeps it in 280 * sync with the data we receive from the backend. 281 */ 282 class RulesetList { 283 elements = { 284 list: document.getElementById("ruleset-list"), 285 emptyContainer: document.getElementById("ruleset-list-empty"), 286 itemTemplate: document.getElementById("ruleset-template"), 287 }; 288 289 nameAttribute = "data-name"; 290 291 rulesets = []; 292 293 constructor() { 294 RPMAddMessageListener( 295 "rulesets:channels-change", 296 this.onRulesetsChanged.bind(this) 297 ); 298 } 299 300 getSelectedRuleset() { 301 const name = this.elements.list 302 .querySelector(".selected") 303 ?.getAttribute(this.nameAttribute); 304 for (const ruleset of this.rulesets) { 305 if (ruleset.name == name) { 306 return ruleset; 307 } 308 } 309 return null; 310 } 311 312 isEmpty() { 313 return !this.rulesets.length; 314 } 315 316 async update() { 317 this.rulesets = await RPMSendQuery("rulesets:get-channels"); 318 await this._populateRulesets(); 319 } 320 321 setItemSelected(name) { 322 name = name.replace(/["\\]/g, "\\$&"); 323 const item = this.elements.list.querySelector( 324 `.item[${this.nameAttribute}="${name}"]` 325 ); 326 this._selectItem(item); 327 } 328 329 async _populateRulesets() { 330 if (this.isEmpty()) { 331 this.elements.emptyContainer.classList.remove("hidden"); 332 } else { 333 this.elements.emptyContainer.classList.add("hidden"); 334 } 335 336 const list = this.elements.list; 337 const selName = list 338 .querySelector(".item.selected") 339 ?.getAttribute(this.nameAttribute); 340 const items = list.querySelectorAll(".item"); 341 for (const item of items) { 342 item.remove(); 343 } 344 345 for (const ruleset of this.rulesets) { 346 const item = this._addItem(ruleset); 347 if (ruleset.name === selName) { 348 this._selectItem(item); 349 } 350 } 351 } 352 353 _addItem(ruleset) { 354 const item = this.elements.itemTemplate.cloneNode(true); 355 item.removeAttribute("id"); 356 item.classList.add("item"); 357 item.querySelector(".name").textContent = ruleset.name; 358 const descr = item.querySelector(".description"); 359 setUpdateDate(ruleset, descr); 360 item.classList.toggle("disabled", !ruleset.enabled); 361 item.setAttribute(this.nameAttribute, ruleset.name); 362 item.addEventListener("click", () => { 363 this.onRulesetClick(ruleset); 364 }); 365 this.elements.list.append(item); 366 return item; 367 } 368 369 _selectItem(item) { 370 this.elements.list.querySelector(".selected")?.classList.remove("selected"); 371 item?.classList.add("selected"); 372 } 373 374 onRulesetClick(ruleset) { 375 gAboutRulesets.setState(States.Details, ruleset); 376 } 377 378 onRulesetsChanged(data) { 379 this.rulesets = data.data; 380 this._populateRulesets(); 381 const selected = this.getSelectedRuleset(); 382 if (selected !== null) { 383 gAboutRulesets.setState(States.Details, selected); 384 } 385 } 386 } 387 388 /** 389 * The entry point of about:rulesets. 390 * It initializes the various states and allows to switch between them. 391 */ 392 class AboutRulesets { 393 _state = null; 394 395 async init() { 396 const args = await RPMSendQuery("rulesets:get-init-args"); 397 const showWarning = args.showWarning; 398 399 this.list = new RulesetList(); 400 this._states = {}; 401 this._states[States.Warning] = new WarningState(); 402 this._states[States.Details] = new DetailsState(); 403 this._states[States.Edit] = new EditState(); 404 this._states[States.NoRulesets] = new NoRulesetsState(); 405 406 await this.refreshRulesets(); 407 408 if (showWarning) { 409 this.setState(States.Warning); 410 } else { 411 this.selectFirst(); 412 } 413 } 414 415 setState(state, ...args) { 416 document.querySelector("body").className = `state-${state}`; 417 this._state?.hide(); 418 this._state = this._states[state]; 419 this._state.show(...args); 420 } 421 422 async refreshRulesets() { 423 await this.list.update(); 424 if (this._state === this._states[States.Details]) { 425 const ruleset = this.list.getSelectedRuleset(); 426 if (ruleset !== null) { 427 this.setState(States.Details, ruleset); 428 } else { 429 this.selectFirst(); 430 } 431 } else if (this.list.isEmpty()) { 432 this.setState(States.NoRulesets); 433 } 434 } 435 436 selectFirst() { 437 if (this.list.isEmpty()) { 438 this.setState(States.NoRulesets); 439 } else { 440 this.setState("details", this.list.rulesets[0]); 441 } 442 } 443 } 444 445 const gAboutRulesets = new AboutRulesets(); 446 gAboutRulesets.init();