diff --git a/plugins/ContentFilter/ContentFilterPlugin.py b/plugins/ContentFilter/ContentFilterPlugin.py new file mode 100644 index 00000000..bde4ce01 --- /dev/null +++ b/plugins/ContentFilter/ContentFilterPlugin.py @@ -0,0 +1,202 @@ +import time +import re + +from Plugin import PluginManager +from Translate import Translate +from Config import config + +from ContentFilterStorage import ContentFilterStorage + + +if "_" not in locals(): + _ = Translate("plugins/ContentFilter/languages/") + + +@PluginManager.registerTo("SiteManager") +class SiteManagerPlugin(object): + def load(self, *args, **kwargs): + global filter_storage + super(SiteManagerPlugin, self).load(*args, **kwargs) + filter_storage = ContentFilterStorage(site_manager=self) + + +@PluginManager.registerTo("UiWebsocket") +class UiWebsocketPlugin(object): + # Mute + def cbMuteAdd(self, to, auth_address, cert_user_id, reason): + filter_storage.file_content["mutes"][auth_address] = { + "cert_user_id": cert_user_id, "reason": reason, "source": self.site.address, "date_added": time.time() + } + filter_storage.save() + filter_storage.changeDbs(auth_address, "remove") + self.response(to, "ok") + + def actionMuteAdd(self, to, auth_address, cert_user_id, reason): + if "ADMIN" in self.getPermissions(to): + self.cbMuteAdd(to, auth_address, cert_user_id, reason) + else: + self.cmd( + "confirm", + [_["Hide all content from %s?"] % cert_user_id, _["Mute"]], + lambda (res): self.cbMuteAdd(to, auth_address, cert_user_id, reason) + ) + + def cbMuteRemove(self, to, auth_address): + del filter_storage.file_content["mutes"][auth_address] + filter_storage.save() + filter_storage.changeDbs(auth_address, "load") + self.response(to, "ok") + + def actionMuteRemove(self, to, auth_address): + if "ADMIN" in self.getPermissions(to): + self.cbMuteRemove(to, auth_address) + else: + self.cmd( + "confirm", + [_["Unmute %s?"] % filter_storage.file_content["mutes"][auth_address]["cert_user_id"], _["Unmute"]], + lambda (res): self.cbMuteRemove(to, auth_address) + ) + + def actionMuteList(self, to): + if "ADMIN" in self.getPermissions(to): + self.response(to, filter_storage.file_content["mutes"]) + else: + return self.response(to, {"error": "Forbidden: Only ADMIN sites can list mutes"}) + + # Siteblock + def actionSiteblockAdd(self, to, site_address, reason=None): + if "ADMIN" not in self.getPermissions(to): + return self.response(to, {"error": "Forbidden: Only ADMIN sites can add to blocklist"}) + filter_storage.file_content["siteblocks"][site_address] = {"date_added": time.time(), "reason": reason} + filter_storage.save() + self.response(to, "ok") + + def actionSiteblockRemove(self, to, site_address): + if "ADMIN" not in self.getPermissions(to): + return self.response(to, {"error": "Forbidden: Only ADMIN sites can remove from blocklist"}) + del filter_storage.file_content["siteblocks"][site_address] + filter_storage.save() + self.response(to, "ok") + + def actionSiteblockList(self, to): + if "ADMIN" in self.getPermissions(to): + self.response(to, filter_storage.file_content["siteblocks"]) + else: + return self.response(to, {"error": "Forbidden: Only ADMIN sites can list blocklists"}) + + # Include + def actionFilterIncludeAdd(self, to, inner_path, description=None, address=None): + if address: + if "ADMIN" not in self.getPermissions(to): + return self.response(to, {"error": "Forbidden: Only ADMIN sites can manage different site include"}) + site = self.server.sites[address] + else: + address = self.site.address + site = self.site + + if "ADMIN" in self.getPermissions(to): + self.cbFilterIncludeAdd(to, True, address, inner_path, description) + else: + content = site.storage.loadJson(inner_path) + title = _["New shared global content filter: %s (%s sites, %s users)"] % ( + inner_path, len(content.get("siteblocks", {})), len(content.get("mutes", {})) + ) + + self.cmd( + "confirm", + [title, "Add"], + lambda (res): self.cbFilterIncludeAdd(to, res, address, inner_path, description) + ) + + def cbFilterIncludeAdd(self, to, res, address, inner_path, description): + if not res: + self.response(to, res) + return False + + filter_storage.includeAdd(address, inner_path, description) + self.response(to, "ok") + + def actionFilterIncludeRemove(self, to, inner_path, address=None): + if address: + if "ADMIN" not in self.getPermissions(to): + return self.response(to, {"error": "Forbidden: Only ADMIN sites can manage different site include"}) + else: + address = self.site.address + + key = "%s/%s" % (address, inner_path) + if key not in filter_storage.file_content["includes"]: + self.response(to, {"error": "Include not found"}) + filter_storage.includeRemove(address, inner_path) + self.response(to, "ok") + + def actionFilterIncludeList(self, to, all_sites=False, filters=False): + if all_sites and "ADMIN" not in self.getPermissions(to): + return self.response(to, {"error": "Forbidden: Only ADMIN sites can list all sites includes"}) + + back = [] + includes = filter_storage.file_content.get("includes", {}).values() + for include in includes: + if not all_sites and include["address"] != self.site.address: + continue + if filters: + content = filter_storage.site_manager.get(include["address"]).storage.loadJson(include["inner_path"]) + include["mutes"] = content.get("mutes", {}) + include["siteblocks"] = content.get("siteblocks", {}) + back.append(include) + self.response(to, back) + + +@PluginManager.registerTo("SiteStorage") +class SiteStoragePlugin(object): + def updateDbFile(self, inner_path, file=None, cur=None): + if file is not False: # File deletion always allowed + # Find for bitcoin addresses in file path + matches = re.findall("/(1[A-Za-z0-9]{26,35})/", inner_path) + # Check if any of the adresses are in the mute list + for auth_address in matches: + if filter_storage.isMuted(auth_address): + self.log.debug("Mute match: %s, ignoring %s" % (auth_address, inner_path)) + return False + + return super(SiteStoragePlugin, self).updateDbFile(inner_path, file=file, cur=cur) + + def onUpdated(self, inner_path, file=None): + file_path = "%s/%s" % (self.site.address, inner_path) + if file_path in filter_storage.file_content["includes"]: + self.log.debug("Filter file updated: %s" % inner_path) + filter_storage.includeUpdateAll() + return super(SiteStoragePlugin, self).onUpdated(inner_path, file=file) + + +@PluginManager.registerTo("UiRequest") +class UiRequestPlugin(object): + def actionWrapper(self, path, extra_headers=None): + match = re.match("/(?P
[A-Za-z0-9\._-]+)(?P/.*|$)", path) + if not match: + return False + address = match.group("address") + + if self.server.site_manager.get(address): # Site already exists + return super(UiRequestPlugin, self).actionWrapper(path, extra_headers) + + if self.server.site_manager.isDomain(address): + address = self.server.site_manager.resolveDomain(address) + + if filter_storage.isSiteblocked(address): + site = self.server.site_manager.get(config.homepage) + if not extra_headers: + extra_headers = {} + self.sendHeader(extra_headers=extra_headers) + return iter([super(UiRequestPlugin, self).renderWrapper( + site, path, "uimedia/plugins/contentfilter/blocklisted.html?address=" + address, + "Blacklisted site", extra_headers, show_loadingscreen=False + )]) + else: + return super(UiRequestPlugin, self).actionWrapper(path, extra_headers) + + def actionUiMedia(self, path, *args, **kwargs): + if path.startswith("/uimedia/plugins/contentfilter/"): + file_path = path.replace("/uimedia/plugins/contentfilter/", "plugins/ContentFilter/media/") + return self.actionFile(file_path) + else: + return super(UiRequestPlugin, self).actionUiMedia(path) diff --git a/plugins/ContentFilter/ContentFilterStorage.py b/plugins/ContentFilter/ContentFilterStorage.py new file mode 100644 index 00000000..17af298f --- /dev/null +++ b/plugins/ContentFilter/ContentFilterStorage.py @@ -0,0 +1,140 @@ +import os +import json +import logging +import collections +import time + +from Debug import Debug +from Plugin import PluginManager +from Config import config +from util import helper + +class ContentFilterStorage(object): + def __init__(self, site_manager): + self.log = logging.getLogger("ContentFilterStorage") + self.file_path = "%s/filters.json" % config.data_dir + self.site_manager = site_manager + self.file_content = self.load() + + # Set default values for filters.json + if not self.file_content: + self.file_content = {} + + # Site blacklist renamed to site blocks + if "site_blacklist" in self.file_content: + self.file_content["siteblocks"] = self.file_content["site_blacklist"] + del self.file_content["site_blacklist"] + + for key in ["mutes", "siteblocks", "includes"]: + if key not in self.file_content: + self.file_content[key] = {} + + self.include_filters = collections.defaultdict(set) # Merged list of mutes and blacklists from all include + self.includeUpdateAll(update_site_dbs=False) + + def load(self): + # Rename previously used mutes.json -> filters.json + if os.path.isfile("%s/mutes.json" % config.data_dir): + self.log.info("Renaming mutes.json to filters.json...") + os.rename("%s/mutes.json" % config.data_dir, self.file_path) + if os.path.isfile(self.file_path): + try: + return json.load(open(self.file_path)) + except Exception as err: + self.log.error("Error loading filters.json: %s" % err) + return None + else: + return None + + def includeUpdateAll(self, update_site_dbs=True): + s = time.time() + new_include_filters = collections.defaultdict(set) + + # Load all include files data into a merged set + for include_path in self.file_content["includes"]: + address, inner_path = include_path.split("/", 1) + try: + content = self.site_manager.get(address).storage.loadJson(inner_path) + except Exception as err: + self.log.warning( + "Error loading include %s: %s" % + (include_path, Debug.formatException(err)) + ) + continue + + for key, val in content.iteritems(): + if type(val) is not dict: + continue + + new_include_filters[key].update(val.keys()) + + mutes_added = new_include_filters["mutes"].difference(self.include_filters["mutes"]) + mutes_removed = self.include_filters["mutes"].difference(new_include_filters["mutes"]) + + self.include_filters = new_include_filters + + if update_site_dbs: + for auth_address in mutes_added: + self.changeDbs(auth_address, "remove") + + for auth_address in mutes_removed: + if not self.isMuted(auth_address): + self.changeDbs(auth_address, "load") + + num_mutes = len(self.include_filters["mutes"]) + num_siteblocks = len(self.include_filters["siteblocks"]) + self.log.debug( + "Loaded %s mutes, %s blocked sites from %s includes in %.3fs" % + (num_mutes, num_siteblocks, len(self.file_content["includes"]), time.time() - s) + ) + + def includeAdd(self, address, inner_path, description=None): + self.file_content["includes"]["%s/%s" % (address, inner_path)] = { + "date_added": time.time(), + "address": address, + "description": description, + "inner_path": inner_path + } + self.includeUpdateAll() + self.save() + + def includeRemove(self, address, inner_path): + del self.file_content["includes"]["%s/%s" % (address, inner_path)] + self.includeUpdateAll() + self.save() + + def save(self): + s = time.time() + helper.atomicWrite(self.file_path, json.dumps(self.file_content, indent=2, sort_keys=True)) + self.log.debug("Saved in %.3fs" % (time.time() - s)) + + def isMuted(self, auth_address): + if auth_address in self.file_content["mutes"] or auth_address in self.include_filters["mutes"]: + return True + else: + return False + + def isSiteblocked(self, address): + if address in self.file_content["siteblocks"] or address in self.include_filters["siteblocks"]: + return True + else: + return False + + # Search and remove or readd files of an user + def changeDbs(self, auth_address, action): + self.log.debug("Mute action %s on user %s" % (action, auth_address)) + res = self.site_manager.list().values()[0].content_manager.contents.db.execute( + "SELECT * FROM content LEFT JOIN site USING (site_id) WHERE inner_path LIKE :inner_path", + {"inner_path": "%%/%s/%%" % auth_address} + ) + for row in res: + site = self.site_manager.sites.get(row["address"]) + if not site: + continue + dir_inner_path = helper.getDirname(row["inner_path"]) + for file_name in site.storage.walk(dir_inner_path): + if action == "remove": + site.storage.onUpdated(dir_inner_path + file_name, False) + else: + site.storage.onUpdated(dir_inner_path + file_name) + site.onFileDone(dir_inner_path + file_name) diff --git a/plugins/ContentFilter/Test/TestContentFilter.py b/plugins/ContentFilter/Test/TestContentFilter.py new file mode 100644 index 00000000..e1b37b16 --- /dev/null +++ b/plugins/ContentFilter/Test/TestContentFilter.py @@ -0,0 +1,82 @@ +import pytest +from ContentFilter import ContentFilterPlugin +from Site import SiteManager + + +@pytest.fixture +def filter_storage(): + ContentFilterPlugin.filter_storage = ContentFilterPlugin.ContentFilterStorage(SiteManager.site_manager) + return ContentFilterPlugin.filter_storage + + +@pytest.mark.usefixtures("resetSettings") +@pytest.mark.usefixtures("resetTempSettings") +class TestContentFilter: + def createInclude(self, site): + site.storage.writeJson("filters.json", { + "mutes": {"1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C": {}}, + "siteblocks": {site.address: {}} + }) + + def testIncludeLoad(self, site, filter_storage): + self.createInclude(site) + filter_storage.file_content["includes"]["%s/%s" % (site.address, "filters.json")] = { + "date_added": 1528295893, + } + + assert not filter_storage.include_filters["mutes"] + assert not filter_storage.isMuted("1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C") + assert not filter_storage.isSiteblocked(site.address) + filter_storage.includeUpdateAll(update_site_dbs=False) + assert len(filter_storage.include_filters["mutes"]) == 1 + assert filter_storage.isMuted("1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C") + assert filter_storage.isSiteblocked(site.address) + + def testIncludeAdd(self, site, filter_storage): + self.createInclude(site) + query_num_json = "SELECT COUNT(*) AS num FROM json WHERE directory = 'users/1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C'" + assert not filter_storage.isSiteblocked(site.address) + assert not filter_storage.isMuted("1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C") + assert site.storage.query(query_num_json).fetchone()["num"] == 2 + + # Add include + filter_storage.includeAdd(site.address, "filters.json") + + assert filter_storage.isSiteblocked(site.address) + assert filter_storage.isMuted("1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C") + assert site.storage.query(query_num_json).fetchone()["num"] == 0 + + # Remove include + filter_storage.includeRemove(site.address, "filters.json") + + assert not filter_storage.isSiteblocked(site.address) + assert not filter_storage.isMuted("1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C") + assert site.storage.query(query_num_json).fetchone()["num"] == 2 + + def testIncludeChange(self, site, filter_storage): + self.createInclude(site) + filter_storage.includeAdd(site.address, "filters.json") + assert filter_storage.isSiteblocked(site.address) + assert filter_storage.isMuted("1J6UrZMkarjVg5ax9W4qThir3BFUikbW6C") + + # Add new blocked site + assert not filter_storage.isSiteblocked("1Hello") + + filter_content = site.storage.loadJson("filters.json") + filter_content["siteblocks"]["1Hello"] = {} + site.storage.writeJson("filters.json", filter_content) + + assert filter_storage.isSiteblocked("1Hello") + + # Add new muted user + query_num_json = "SELECT COUNT(*) AS num FROM json WHERE directory = 'users/1C5sgvWaSgfaTpV5kjBCnCiKtENNMYo69q'" + assert not filter_storage.isMuted("1C5sgvWaSgfaTpV5kjBCnCiKtENNMYo69q") + assert site.storage.query(query_num_json).fetchone()["num"] == 2 + + filter_content["mutes"]["1C5sgvWaSgfaTpV5kjBCnCiKtENNMYo69q"] = {} + site.storage.writeJson("filters.json", filter_content) + + assert filter_storage.isMuted("1C5sgvWaSgfaTpV5kjBCnCiKtENNMYo69q") + assert site.storage.query(query_num_json).fetchone()["num"] == 0 + + diff --git a/plugins/ContentFilter/Test/conftest.py b/plugins/ContentFilter/Test/conftest.py new file mode 100644 index 00000000..634e66e2 --- /dev/null +++ b/plugins/ContentFilter/Test/conftest.py @@ -0,0 +1 @@ +from src.Test.conftest import * diff --git a/plugins/ContentFilter/Test/pytest.ini b/plugins/ContentFilter/Test/pytest.ini new file mode 100644 index 00000000..d09210d1 --- /dev/null +++ b/plugins/ContentFilter/Test/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +python_files = Test*.py +addopts = -rsxX -v --durations=6 +markers = + webtest: mark a test as a webtest. \ No newline at end of file diff --git a/plugins/ContentFilter/__init__.py b/plugins/ContentFilter/__init__.py new file mode 100644 index 00000000..4d8c3acc --- /dev/null +++ b/plugins/ContentFilter/__init__.py @@ -0,0 +1 @@ +import ContentFilterPlugin diff --git a/plugins/ContentFilter/languages/hu.json b/plugins/ContentFilter/languages/hu.json new file mode 100644 index 00000000..9b57e697 --- /dev/null +++ b/plugins/ContentFilter/languages/hu.json @@ -0,0 +1,6 @@ +{ + "Hide all content from %s?": "%s tartalmaniak elrejtése?", + "Mute": "Elnémítás", + "Unmute %s?": "%s tartalmaniak megjelenítése?", + "Unmute": "Némítás visszavonása" +} diff --git a/plugins/ContentFilter/languages/it.json b/plugins/ContentFilter/languages/it.json new file mode 100644 index 00000000..9a2c6761 --- /dev/null +++ b/plugins/ContentFilter/languages/it.json @@ -0,0 +1,6 @@ +{ + "Hide all content from %s?": "%s Vuoi nascondere i contenuti di questo utente ?", + "Mute": "Attiva Silenzia", + "Unmute %s?": "%s Vuoi mostrare i contenuti di questo utente ?", + "Unmute": "Disattiva Silenzia" +} diff --git a/plugins/ContentFilter/languages/zh-tw.json b/plugins/ContentFilter/languages/zh-tw.json new file mode 100644 index 00000000..0995f3a0 --- /dev/null +++ b/plugins/ContentFilter/languages/zh-tw.json @@ -0,0 +1,6 @@ +{ + "Hide all content from %s?": "屏蔽 %s 的所有內容?", + "Mute": "屏蔽", + "Unmute %s?": "對 %s 解除屏蔽?", + "Unmute": "解除屏蔽" +} diff --git a/plugins/ContentFilter/languages/zh.json b/plugins/ContentFilter/languages/zh.json new file mode 100644 index 00000000..bf63f107 --- /dev/null +++ b/plugins/ContentFilter/languages/zh.json @@ -0,0 +1,6 @@ +{ + "Hide all content from %s?": "屏蔽 %s 的所有内容?", + "Mute": "屏蔽", + "Unmute %s?": "对 %s 解除屏蔽?", + "Unmute": "解除屏蔽" +} diff --git a/plugins/ContentFilter/media/blocklisted.html b/plugins/ContentFilter/media/blocklisted.html new file mode 100644 index 00000000..33930b26 --- /dev/null +++ b/plugins/ContentFilter/media/blocklisted.html @@ -0,0 +1,86 @@ + + + + + +
+

Site blocked

+

This site is on your blocklist:

+
+
Too much image
+
on 2015-01-25 12:32:11
+
+
Remove from blocklist
+
+ + + + + + diff --git a/plugins/ContentFilter/media/js/ZeroFrame.js b/plugins/ContentFilter/media/js/ZeroFrame.js new file mode 100644 index 00000000..d6facdbf --- /dev/null +++ b/plugins/ContentFilter/media/js/ZeroFrame.js @@ -0,0 +1,119 @@ +// Version 1.0.0 - Initial release +// Version 1.1.0 (2017-08-02) - Added cmdp function that returns promise instead of using callback +// Version 1.2.0 (2017-08-02) - Added Ajax monkey patch to emulate XMLHttpRequest over ZeroFrame API + +const CMD_INNER_READY = 'innerReady' +const CMD_RESPONSE = 'response' +const CMD_WRAPPER_READY = 'wrapperReady' +const CMD_PING = 'ping' +const CMD_PONG = 'pong' +const CMD_WRAPPER_OPENED_WEBSOCKET = 'wrapperOpenedWebsocket' +const CMD_WRAPPER_CLOSE_WEBSOCKET = 'wrapperClosedWebsocket' + +class ZeroFrame { + constructor(url) { + this.url = url + this.waiting_cb = {} + this.wrapper_nonce = document.location.href.replace(/.*wrapper_nonce=([A-Za-z0-9]+).*/, "$1") + this.connect() + this.next_message_id = 1 + this.init() + } + + init() { + return this + } + + connect() { + this.target = window.parent + window.addEventListener('message', e => this.onMessage(e), false) + this.cmd(CMD_INNER_READY) + } + + onMessage(e) { + let message = e.data + let cmd = message.cmd + if (cmd === CMD_RESPONSE) { + if (this.waiting_cb[message.to] !== undefined) { + this.waiting_cb[message.to](message.result) + } + else { + this.log("Websocket callback not found:", message) + } + } else if (cmd === CMD_WRAPPER_READY) { + this.cmd(CMD_INNER_READY) + } else if (cmd === CMD_PING) { + this.response(message.id, CMD_PONG) + } else if (cmd === CMD_WRAPPER_OPENED_WEBSOCKET) { + this.onOpenWebsocket() + } else if (cmd === CMD_WRAPPER_CLOSE_WEBSOCKET) { + this.onCloseWebsocket() + } else { + this.onRequest(cmd, message) + } + } + + onRequest(cmd, message) { + this.log("Unknown request", message) + } + + response(to, result) { + this.send({ + cmd: CMD_RESPONSE, + to: to, + result: result + }) + } + + cmd(cmd, params={}, cb=null) { + this.send({ + cmd: cmd, + params: params + }, cb) + } + + cmdp(cmd, params={}) { + return new Promise((resolve, reject) => { + this.cmd(cmd, params, (res) => { + if (res && res.error) { + reject(res.error) + } else { + resolve(res) + } + }) + }) + } + + send(message, cb=null) { + message.wrapper_nonce = this.wrapper_nonce + message.id = this.next_message_id + this.next_message_id++ + this.target.postMessage(message, '*') + if (cb) { + this.waiting_cb[message.id] = cb + } + } + + log(...args) { + console.log.apply(console, ['[ZeroFrame]'].concat(args)) + } + + onOpenWebsocket() { + this.log('Websocket open') + } + + onCloseWebsocket() { + this.log('Websocket close') + } + + monkeyPatchAjax() { + var page = this + XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open + this.cmd("wrapperGetAjaxKey", [], (res) => { this.ajax_key = res }) + var newOpen = function (method, url, async) { + url += "?ajax_key=" + page.ajax_key + return this.realOpen(method, url, async) + } + XMLHttpRequest.prototype.open = newOpen + } +}