/** * This file is part of Haketilo. * * Function: Install mappings/resources in Haketilo. * * Copyright (C) 2022 Wojtek Kosior * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * As additional permission under GNU GPL version 3 section 7, you * may distribute forms of that code without the copy of the GNU * GPL normally required by section 4, provided you include this * license notice and, in case of non-source distribution, a URL * through which recipients can access the Corresponding Source. * If you modify file(s) with this exception, you may extend this * exception to your version of the file(s), but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * As a special exception to the GPL, any HTML file which merely * makes function calls to this code, and for that purpose * includes it by reference shall be deemed a separate work for * copyright law purposes. If you modify this code, you may extend * this exception to your version of the code, but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * I, Wojtek Kosior, thereby promise not to sue for violation of this file's * license. Although I request that you do not make use of this code in a * proprietary program, I am not going to enforce this in court. */ #IMPORT common/indexeddb.js AS haketilodb #IMPORT html/dialog.js #IMPORT html/item_preview.js AS ip #FROM common/browser.js IMPORT browser #FROM html/DOM_helpers.js IMPORT clone_template, Showable #FROM common/entities.js IMPORT item_id_string, version_string, get_files #FROM common/misc.js IMPORT sha256_async AS compute_sha256 #FROM common/jsonschema.js IMPORT haketilo_validator, haketilo_schemas #FROM common/entities.js IMPORT parse_schema_uri #FROM html/repo_query_cacher_client.js IMPORT indirect_fetch const coll = new Intl.Collator(); /* * Comparator used to sort items in the order we want them to appear in * install dialog: first mappings alphabetically, then resources alphabetically. */ function compare_items(def1, def2) { if (def1.type !== def2.type) return def1.type === "mapping" ? -1 : 1; const name_comparison = coll.compare(def1.long_name, def2.long_name); return name_comparison === 0 ? coll.compare(def1.identifier, def2.identifier) : name_comparison; } function ItemEntry(install_view, item) { Object.assign(this, clone_template("install_list_entry")); this.item_def = item.def; this.item_name.innerText = item.def.long_name; this.item_id.innerText = item_id_string(item.def); if (item.db_def) { this.old_ver.innerText = version_string(item.db_def.version, item.db_def.revision); this.update_info.classList.remove("hide"); } let preview_cb = () => install_view.preview_item(item.def); preview_cb = install_view.dialog_ctx.when_hidden(preview_cb); this.details_but.addEventListener("click", preview_cb); } const container_ids = [ "install_preview", "dialog_container", "mapping_preview_container", "resource_preview_container" ]; /* * Work object is used to communicate between asynchronously executing * functions when computing dependencies tree of an item and when fetching * files for installation. */ async function init_work() { const work = { waiting: 0, is_ok: true, db: (await haketilodb.get()), result: [] }; work.err = function (error, user_message) { if (!this.is_ok) return; if (error) console.error("Haketilo:", error); work.is_ok = false; work.reject_cb(user_message); } return [work, new Promise((...cbs) => [work.resolve_cb, work.reject_cb] = cbs)]; } function InstallView(tab_id, on_view_show, on_view_hide) { Showable.call(this, on_view_show, on_view_hide); Object.assign(this, clone_template("install_view")); const show_container = name => { for (const cid of container_ids) { if (cid !== name) this[cid].classList.add("hide"); } this[name].classList.remove("hide"); } this.dialog_ctx = dialog.make(() => show_container("dialog_container"), () => show_container("install_preview")); this.dialog_container.prepend(this.dialog_ctx.main_div); /* Make a link to view a file from the repository. */ const make_file_link = (preview_ctx, file_ref) => { const a = document.createElement("a"); a.href = `${this.repo_url}file/sha256/${file_ref.sha256}`; a.innerText = file_ref.file; return a; } this.previews_ctx = {}; this.preview_item = item_def => { if (!this.shown) return; const fun = ip[`${item_def.type}_preview`]; const preview_ctx = fun(item_def, this.previews_ctx[item_def.type], make_file_link); this.previews_ctx[item_def.type] = preview_ctx; const container_name = `${item_def.type}_preview_container`; show_container(container_name); this[container_name].prepend(preview_ctx.main_div); } let back_cb = () => show_container("install_preview"); back_cb = this.dialog_ctx.when_hidden(back_cb); for (const type of ["resource", "mapping"]) this[`${type}_back_but`].addEventListener("click", back_cb); const process_item = async (work, item_type, id, ver) => { if (!work.is_ok || work.processed_by_type[item_type].has(id)) return; work.processed_by_type[item_type].add(id); work.waiting++; const url = ver ? `${this.repo_url}${item_type}/${id}/${ver.join(".")}` : `${this.repo_url}${item_type}/${id}.json`; try { var response = await indirect_fetch(tab_id, url); } catch(e) { return work.err(e, "Failure to communicate with repository :("); } if (!work.is_ok) return; if (!response.ok) { return work.err(null, `Repository sent HTTP code ${response.status} :(`); } try { var json = await response.json(); } catch(e) { return work.err(e, "Repository's response is not valid JSON :("); } if (!work.is_ok) return; const captype = item_type[0].toUpperCase() + item_type.substring(1); const nonconforming_format_error_msg = `${captype} ${item_id_string(id, ver)} was served using a nonconforming response format.`; try { const parsed = parse_schema_uri(json.$schema); var major_schema_version = parsed.major; if (!["1", "2"].includes(major_schema_version)) { const msg = `${captype} ${item_id_string(id, ver)} was served using unsupported Hydrilla API version. You might need to update Haketilo.`; return work.err(null, msg); } } catch(e) { return work.err(e, nonconforming_format_error_msg); } const schema_name = `api_${item_type}_description-${major_schema_version}.schema.json`; const schema = haketilo_schemas[schema_name]; const result = haketilo_validator.validate(json, schema); if (result.errors.length > 0) return work.err(result.errors, nonconforming_format_error_msg); const scripts = item_type === "resource" && json.scripts; const files = json.source_copyright.concat(scripts || []); if (item_type === "mapping") { for (const res_ref of Object.values(json.payloads || {})) process_item(work, "resource", res_ref.identifier); } else { for (const res_ref of (json.dependencies || [])) process_item(work, "resource", res_ref.identifier); } if (major_schema_version >= 2) { for (const map_ref of (json.required_mappings || [])) process_item(work, "mapping", map_ref.identifier); } /* * At this point we already have JSON definition of the item and we * triggered processing of its dependencies. We now have to verify if * the same or newer version of the item is already present in the * database and if so - omit this item. */ const transaction = work.db.transaction(item_type); try { var db_def = await haketilodb.idb_get(transaction, item_type, id); if (!work.is_ok) return; } catch(e) { const msg = "Error accessing Haketilo's internal database :("; return work.err(e, msg); } if (!db_def || db_def.version < json.version) work.result.push({def: json, db_def}); if (--work.waiting === 0) work.resolve_cb(work.result); } async function compute_deps(item_type, item_id, item_ver) { const [work, work_prom] = await init_work(); work.processed_by_type = {"mapping" : new Set(), "resource": new Set()}; process_item(work, item_type, item_id, item_ver); const items = await work_prom; items.sort((i1, i2) => compare_items(i1.def, i2.def)); return items; } const show_super = this.show; this.show = async (repo_url, item_type, item_id, item_ver) => { if (!show_super()) return; this.repo_url = repo_url; dialog.loader(this.dialog_ctx, "Fetching data from repository..."); try { var items = await compute_deps(item_type, item_id, item_ver); } catch(e) { var dialog_prom = dialog.error(this.dialog_ctx, e); } if (!dialog_prom && items.length === 0) { const msg = "Nothing to do - packages already installed."; var dialog_prom = dialog.info(this.dialog_ctx, msg); } if (dialog_prom) { dialog.close(this.dialog_ctx); await dialog_prom; this.hide(); return; } this.item_entries = items.map(i => new ItemEntry(this, i)); this.to_install_list.append(...this.item_entries.map(ie => ie.main_li)); dialog.close(this.dialog_ctx); } const process_file = async (work, sha256) => { if (!work.is_ok) return; work.waiting++; try { var file_uses = await haketilodb.idb_get(work.file_uses_transaction, "file_uses", sha256); if (!work.is_ok) return; } catch(e) { const msg = "Error accessing Haketilo's internal database :("; return work.err(e, msg); } if (!file_uses) { const url = `${this.repo_url}file/sha256/${sha256}`; try { var response = await fetch(url); if (!work.is_ok) return; } catch(e) { const msg = "Failure to communicate with repository :("; return work.err(e, msg); } if (!response.ok) { const msg = `Repository sent HTTP code ${response.status} :(`; return work.err(null, msg); } const text = await response.text(); if (!work.is_ok) return; const digest = await compute_sha256(text); if (!work.is_ok) return; if (digest !== sha256) { const msg = `${url} served a file with different SHA256 cryptographic sum :(`; return work.err(null, msg); } work.result.push([sha256, text]); } if (--work.waiting === 0) work.resolve_cb(work.result); } const get_missing_files = async item_defs => { const [work, work_prom] = await init_work(); work.file_uses_transaction = work.db.transaction("file_uses"); const processed_files = new Set(); for (const item_def of item_defs) { for (const file of get_files(item_def)) { if (!processed_files.has(file.sha256)) { processed_files.add(file.sha256); process_file(work, file.sha256); } } } return processed_files.size > 0 ? work_prom : []; } const perform_install = async () => { if (!this.show || !this.item_entries) return; dialog.loader(this.dialog_ctx, "Installing..."); const item_defs = this.item_entries.map(ie => ie.item_def); try { var files = (await get_missing_files(item_defs)) .reduce((ac, [h, txt]) => Object.assign(ac, {[h]: txt}), {}); } catch(e) { var dialog_prom = dialog.error(this.dialog_ctx, e); } if (files !== undefined) { const data = {file: {sha256: files}}; for (const type of ["resource", "mapping"]) { const set = {}; for (const def of item_defs.filter(def => def.type === type)) set[def.identifier] = {[version_string(def.version)]: def}; data[type] = set; } try { await haketilodb.save_items(data); } catch(e) { console.error("Haketilo:", e); const msg = "Error writing to Haketilo's internal database :("; var dialog_prom = dialog.error(this.dialog_ctx, msg); } } if (!dialog_prom) { const msg = "Successfully installed!"; var dialog_prom = dialog.info(this.dialog_ctx, msg); } dialog.close(this.dialog_ctx); await dialog_prom; this.hide(); } const hide_super = this.hide; this.hide = () => { if (!hide_super()) return; delete this.item_entries; [...this.to_install_list.children].forEach(n => n.remove()); } const hide_cb = this.dialog_ctx.when_hidden(this.hide); this.cancel_but.addEventListener("click", hide_cb); const install_cb = this.dialog_ctx.when_hidden(perform_install); this.install_but.addEventListener("click", install_cb); } #EXPORT InstallView