aboutsummaryrefslogtreecommitdiff
/**
 * This file is part of Haketilo.
 *
 * Function: Driving a list of editable entries. Used to make UI for management
 *           of repo URLs and script allowing/blocking rules.
 *
 * 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 html/dialog.js
#IMPORT common/indexeddb.js AS haketilodb

#FROM html/DOM_helpers.js IMPORT clone_template
#FROM common/patterns.js  IMPORT validate_normalize_url_pattern

const coll = new Intl.Collator();

function Entry(text, list, entry_idx) {
    Object.assign(this, clone_template("text_entry"));

    const editable = () => list.active_entry === this;
    this.exists = () => text !== null;

    /*
     * Called in the constructor when creating a completely new entry and as a
     * result of clicking on an existing entry in the list.
     */
    this.make_editable = () => {
	if (editable())
	    return;

	if (list.active_entry !== null)
	    list.active_entry.make_noneditable();

	list.active_entry = this;

	this.editable_view.classList.remove("hide");
	this.noneditable_view.classList.add("hide");

	this.input.value = text || "";
    }

    /*
     * Called when 'Cancel' is clicked, when another entry becomes editable and
     * when an entry ends being modified.
     */
    this.make_noneditable = () => {
	if (!editable())
	    return;

	list.active_entry = null;

	if (!this.exists()) {
	    this.main_div.remove();
	    return;
	}

	this.editable_view.classList.add("hide");
	this.noneditable_view.classList.remove("hide");
    }

    /*
     * The *_cb() calls are allowed to throw if an error occurs. It is expected
     * *_cb() will show some dialog that will block other clicks until they
     * returns/throw.
     */

    const add_clicked = async () => {
	if (!editable() || this.exists())
	    return;

	await list.create_cb(this.input.value);
	this.make_noneditable();
    }

    /*
     * Changing entry's text is not handled here, instead we wait for subsequent
     * item removal and creation requests from the outside.
     */
    const save_clicked = async () => {
	if (!editable() || !this.exists())
	    return;

	await list.replace_cb(text, this.input.value);
	this.make_noneditable();
    }

    const enter_hit = () => add_clicked().then(save_clicked);

    /*
     * Removing entry from the list is not handled here, instead we wait for
     * subsequent item removal requests from the outside.
     */
    const remove_clicked = async () => {
	if (editable() || !this.exists())
	    return;

	await list.remove_cb(text);
    }

    /*
     * Called from the outside after the entry got removed from the database. It
     * is assumed entry_exists() is true when this is called.
     */
    this.remove = () => {
	if (editable()) {
	    text = null;
	    this.save_but.classList.add("hide");
	    this.add_but.classList.remove("hide");
	} else {
	    this.main_div.remove();
	}
    }

    if (this.exists()) {
	this.text.innerText = text;
    } else {
	this.save_but.classList.add("hide");
	this.add_but.classList.remove("hide");
	this.make_editable();
    }

    for (const [node, cb] of [
	[this.save_but,         save_clicked],
	[this.add_but,          add_clicked],
	[this.remove_but,       remove_clicked],
	[this.cancel_but,       this.make_noneditable],
	[this.noneditable_view, this.make_editable],
    ])
	node.addEventListener("click", list.dialog_ctx.when_hidden(cb));

    const enter_cb = e => (e.key === 'Enter') && enter_hit();
    this.input.addEventListener("keypress",
				list.dialog_ctx.when_hidden(enter_cb));

    if (entry_idx > 0) {
	const prev_text = list.shown_texts[entry_idx - 1];
	list.entries_by_text.get(prev_text).main_div.after(this.main_div);
    } else {
	if (!editable() && list.active_entry && !list.active_entry.exists())
	    list.active_entry.main_div.after(this.main_div);
	else
	    list.list_div.prepend(this.main_div);
    }
}

function TextEntryList(dialog_ctx, destroy_cb,
		       remove_cb, create_cb, replace_cb) {
    Object.assign(this, {dialog_ctx, remove_cb, create_cb, replace_cb});
    Object.assign(this, clone_template("text_entry_list"));
    this.ul = document.createElement("ul");
    this.shown_texts = [];
    this.entries_by_text = new Map();
    this.active_entry = null;

    const find_entry_idx = text => {
	let left = 0, right = this.shown_texts.length;

	while (left < right) {
	    const mid = (left + right) >> 1;
	    if (coll.compare(text, this.shown_texts[mid]) > 0)
		left = mid + 1;
	    else /* <= 0 */
		right = mid;
	}

	return left;
    }

    this.remove = text => {
	if (!this.entries_by_text.has(text))
	    return;

	this.shown_texts.splice(find_entry_idx(text), 1);
	this.entries_by_text.get(text).remove();
	this.entries_by_text.delete(text)
    }

    this.add = text => {
	if (this.entries_by_text.has(text))
	    return;

	const idx = find_entry_idx(text);
	this.entries_by_text.set(text, new Entry(text, this, idx));
	this.shown_texts.splice(idx, 0, text);
    }

    this.destroy = () => {
	this.main_div.remove();
	destroy_cb();
    }

    const add_new = () => new Entry(null, this, 0);

    this.new_but.addEventListener("click", dialog_ctx.when_hidden(add_new));
}

async function repo_list(dialog_ctx) {
    let list;

    function validate_normalize(repo_url) {
	let error_msg;

	/* In the future we might also try making a test connection. */
	if (!/^https:\/\/[^/.]+\.[^/.]+/.test(repo_url))
	    error_msg = "Provided URL is not valid.";

	if (!/^https:\/\//.test(repo_url))
	    error_msg = "Repository URLs shoud use https:// schema.";

	/* Make exception for localhost while disallowing http://. */
	if (/^http:\/\/(127\.0\.0\.1|localhost)([:/].*)?$/.test(repo_url))
	    error_msg = null;

	if (error_msg) {
	    dialog.error(dialog_ctx, error_msg);
	    throw error_msg;
	}

	return repo_url.replace(/\/*$/, "/");
    }

    async function remove_repo(repo_url) {
	dialog.loader(dialog_ctx, `Removing repository '${repo_url}'...`);

	try {
	    await haketilodb.del_repo(repo_url);
	    var removing_ok = true;
	} finally {
	    if (!removing_ok) {
		dialog.error(dialog_ctx,
			     `Failed to remove repository '${repo_url}' :(`);
	    }

	    dialog.close(dialog_ctx);
	}
    }

    async function create_repo(repo_url) {
	repo_url = validate_normalize(repo_url);

	dialog.loader(dialog_ctx, `Adding repository '${repo_url}'...`);

	try {
	    await haketilodb.set_repo(repo_url);
	    var adding_ok = true;
	} finally {
	    if (!adding_ok)
		dialog.error(dialog_ctx, `Failed to add repository '${repo_url}' :(`);

	    dialog.close(dialog_ctx);
	}
    }

    async function replace_repo(old_repo_url, new_repo_url) {
	if (old_repo_url === new_repo_url)
	    return;

	new_repo_url = validate_normalize(new_repo_url);

	dialog.loader(dialog_ctx, "Replacing repository...");

	try {
	    await haketilodb.set_repo(new_repo_url);
	    await haketilodb.del_repo(old_repo_url);
	    var replacing_ok = true;
	} finally {
	    if (!replacing_ok)
		dialog.error(dialog_ctx, "Failed to replace repository :(");

	    dialog.close(dialog_ctx);
	}
    }

    function onchange(change) {
	if (change.new_val)
	    list.add(change.key);
	else
	    list.remove(change.key);
    }

    dialog.loader(dialog_ctx, "Loading repositories...");
    const [tracking, items] = await haketilodb.track.repo(onchange);
    dialog.close(dialog_ctx);

    list = new TextEntryList(dialog_ctx, () => haketilodb.untrack(tracking),
			     remove_repo, create_repo, replace_repo);

    items.forEach(item => list.add(item.url));

    return list;
}
#EXPORT repo_list

async function blocking_allowing_lists(dialog_ctx) {
    function validate_normalize(url_pattern) {
	try {
	    return validate_normalize_url_pattern(url_pattern);
	} catch(e) {
	    dialog.error(dialog_ctx, e);
	    throw e;
	}
    }

    async function default_allowing_on(url_pattern, allow) {
	dialog.loader(dialog_ctx,
		      `Setting default scripts blocking policy on '${url_pattern}'...`);

	try {
	    await haketilodb.set_default_allowing(url_pattern);
	    var default_allowing_ok = true;
	} finally {
	    if (!default_allowing_ok) {
		dialog.error(dialog_ctx,
			     `Failed to remove rule for '${url_pattern}' :(`);
	    }

	    dialog.close(dialog_ctx);
	}
    }

    async function set_allowing_on(url_pattern, allow) {
	url_pattern = validate_normalize(url_pattern);

	const [action, action_cap] = allow ?
	      ["allowing", "Allowing"] : ["blocking", "Blocking"];
	dialog.loader(dialog_ctx, `${action_cap} scripts on '${url_pattern}'...`);

	try {
	    await haketilodb.set_allowed(url_pattern, allow);
	    var set_allowing_ok = true;
	} finally {
	    if (!set_allowing_ok)
		dialog.error(dialog_ctx,
			     `Failed to write ${action} rule for '${url_pattern}' :(`);

	    dialog.close(dialog_ctx);
	}
    }

    async function replace_allowing_on(old_pattern, new_pattern, allow) {
	new_pattern = validate_normalize(new_pattern);
	if (old_pattern === new_pattern)
	    return;

	const action = allow ? "allowing" : "blocking";
	dialog.loader(dialog_ctx, `Rewriting script ${action} rule...`);

	try {
	    await haketilodb.set_allowed(new_pattern, allow);
	    await haketilodb.set_default_allowing(old_pattern);
	    var replace_allowing_ok = true;
	} finally {
	    if (!replace_allowing_ok)
		dialog.error(dialog_ctx, `Failed to rewrite ${action} rule :(`);

	    dialog.close(dialog_ctx);
	}
    }

    let blocking_list, allowing_list;

    function onchange(change) {
	if (change.new_val) {
	    if (change.new_val.allow)
		var [to_add, to_remove] = [allowing_list, blocking_list];
	    else
		var [to_add, to_remove] = [blocking_list, allowing_list];

	    to_add.add(change.key);
	    to_remove.remove(change.key);
	} else {
	    blocking_list.remove(change.key);
	    allowing_list.remove(change.key);
	}
    }

    dialog.loader(dialog_ctx, "Loading script blocking settings...");
    const [tracking, items] = await haketilodb.track.blocking(onchange);
    dialog.close(dialog_ctx);

    let untrack_called = 0;
    function untrack() {
	if (++untrack_called === 2)
	    haketilodb.untrack(tracking);
    }

    const lists = [];
    for (const allow of [false, true]) {
	lists[allow + 0] =
	    new TextEntryList(dialog_ctx, untrack,
			      pattern => default_allowing_on(pattern),
			      pattern => set_allowing_on(pattern, allow),
			      (p1, p2) => replace_allowing_on(p1, p2, allow));
    }
    [blocking_list, allowing_list] = lists;

    for (const item of items) {
	if (item.allow)
	    allowing_list.add(item.pattern);
	else
	    blocking_list.add(item.pattern);
    }

    return lists;
}
#EXPORT blocking_allowing_lists