diff --git a/convo.py b/convo.py index 6b922eb0..bf9ccb6d 100644 --- a/convo.py +++ b/convo.py @@ -14,6 +14,7 @@ from dataobjs import PesterHistory from parsetools import convertTags, lexMessage, mecmd, colorBegin, colorEnd, smiledict import parsetools +import embeds PchumLog = logging.getLogger("pchumLogger") @@ -382,6 +383,12 @@ def __init__(self, theme, parent=None): QtCore.QUrl("smilies/%s" % (smiledict[k])), "smilies/%s" % (smiledict[k]), ) + + embeds.manager.embed_loading.connect(self.registerEmbed) + embeds.manager.embed_loaded.connect(self.showEmbed) + embeds.manager.embed_failed.connect(self.showEmbedError) + for embed in embeds.manager.get_embeds(): + self.showEmbed(embed) # self.mainwindow.animationSetting[bool].connect(self.animateChanged) def addAnimation(self, url, fileName): @@ -397,6 +404,33 @@ def addAnimation(self, url, fileName): self.urls[movie] = url movie.frameChanged.connect(movie.animate) # (int frameNumber) + def setResource(self, uri, pixmap): + try: + # PyQt6 + resource_type = QtGui.QTextDocument.ResourceType.ImageResource.value + except AttributeError: + # PyQt5 + resource_type = QtGui.QTextDocument.ResourceType.ImageResource + self.document().addResource( + resource_type, + QtCore.QUrl(uri), + pixmap, + ) + self.setLineWrapColumnOrWidth(self.lineWrapColumnOrWidth()) + + def registerEmbed(self, url): + if embeds.manager.has_embed(url): + self.showEmbed(url) + else: + self.setResource(url, QtGui.QPixmap("img/loading_embed.png")) + + def showEmbed(self, url): + self.setResource(url, embeds.manager.get_embed(url)) + + def showEmbedError(self, url, error=None): + # Sets the resource to generic "wuh oh failed" image + self.setResource(url, QtGui.QPixmap("img/embed_failed.png")) + """ @QtCore.pyqtSlot(bool) def animateChanged(self, animate): diff --git a/embeds.py b/embeds.py new file mode 100644 index 00000000..f5f156e0 --- /dev/null +++ b/embeds.py @@ -0,0 +1,118 @@ +from collections import OrderedDict +import logging + +try: + from PyQt6 import QtCore, QtGui, QtWidgets, QtNetwork +except ImportError: + print("PyQt5 fallback (thememanager.py)") + from PyQt5 import QtCore, QtGui, QtWidgets, QtNetwork + +from theme_repo_manager import get_request + + +PchumLog = logging.getLogger("pchumLogger") + +## embeds.py +# system for fetching & displaying image previews for image URLs +# has a single instance (singleton) called `manager` at the bottom of this file + +## TODO?: +## - add "ignore filters" or whatever toggle in settings. slight security risk (leaking IP) + + +class EmbedsManager(QtCore.QObject): + cache = OrderedDict() + downloading = set() + max_items = 50 + + mainwindow = None + + embed_loading = QtCore.pyqtSignal(str) # when the get request starts (url: str) + embed_loaded = QtCore.pyqtSignal( + str + ) # when the embed is done downloading (url: str) + embed_purged = QtCore.pyqtSignal( + str + ) # when the embed is cleared from memory (url: str) + embed_failed = QtCore.pyqtSignal( + str, str + ) # when the embed fails to load (url: str, reason: str) + + def __init__(self): + super().__init__() + + def get_embeds(self): + """Returns all cached embeds""" + return list(self.cache.keys()) + + def get_embed(self, url, placeholder=None): + """Returns the QPixmap object of the embed image after fetching + Should be called when the embed_loaded signal has been emitted, OR after checking that has_embed == True + """ + if url in self.cache: + self.cache.move_to_end(url) + # make sure that embeds that were fetched a while ago but recently used do not get purged first + return self.cache.get(url, placeholder) + + def has_embed(self, url): + return url in self.cache + + def check_trustlist(self, url): + for item in self.mainwindow.userprofile.getTrustedDomains(): + if url.startswith(item): + return True + return False + + def fetch_embed(self, url, ignore_cache=False): + """Downloads a new embed if it does not exist yet""" + + if not self.check_trustlist(url): + PchumLog.warning( + "Requested embed fetch of %s denied because it does not match the trust filter.", + url, + ) + return + + if not ignore_cache and self.has_embed(url): + PchumLog.debug( + "Requested embed fetch of %s, but it was already fetched", url + ) + return + elif url in self.downloading: + PchumLog.debug( + "Requested embed fetch of %s, but it is already being fetched", url + ) + return + + PchumLog.info("Fetching embed of %s", url) + + self.downloading.add(url) + # Track which embeds are downloading so we dont do double-fetches + + self.embed_loading.emit(url) + reply = get_request(url) + reply.finished.connect(lambda: self._on_request_finished(reply, url)) + + def _on_request_finished(self, reply, url): + """Callback, called when an embed has finished downloading""" + self.downloading.remove(url) + if reply.error() == QtNetwork.QNetworkReply.NetworkError.NoError: + PchumLog.info(f"Finished fetching embed {url}") + + pixmap = QtGui.QPixmap() + pixmap.loadFromData(reply.readAll()) + + self.cache[url] = pixmap + self.embed_loaded.emit(url) + + if len(self.cache) > self.max_items: + to_purge = list(self.cache.keys())[0] + PchumLog.debug("Purging embed %s", to_purge) + self.embed_purged.emit(to_purge) + del self.cache[to_purge] + else: + PchumLog.error("Error fetching embed %s: %s", url, reply.error()) + self.embed_failed.emit(url, str(reply.error())) + + +manager = EmbedsManager() diff --git a/img/embed_failed.png b/img/embed_failed.png new file mode 100644 index 00000000..be33e587 Binary files /dev/null and b/img/embed_failed.png differ diff --git a/img/loading_embed.png b/img/loading_embed.png new file mode 100644 index 00000000..d06a89c6 Binary files /dev/null and b/img/loading_embed.png differ diff --git a/menus.py b/menus.py index 14818353..64abf50f 100644 --- a/menus.py +++ b/menus.py @@ -1384,6 +1384,63 @@ def __init__(self, config, theme, parent): self.userlinkscheck.setChecked(self.config.disableUserLinks()) self.userlinkscheck.setVisible(False) + self.label_trusteddomains = QtWidgets.QLabel("Trusted image domains:") + self.list_trusteddomains = QtWidgets.QListWidget() + self.list_trusteddomains.addItems(parent.userprofile.getTrustedDomains()) + self.hbox_trusteddomains_buttons = QtWidgets.QHBoxLayout() + self.button_trusteddomains_add = QtWidgets.QPushButton("Add") + self.button_trusteddomains_remove = QtWidgets.QPushButton("Remove") + self.hbox_trusteddomains_buttons.addWidget(self.button_trusteddomains_add) + self.hbox_trusteddomains_buttons.addWidget(self.button_trusteddomains_remove) + self.label_trusteddomains_info = QtWidgets.QLabel( + "When an image link is sent in a conversation that belongs to one of these domains, pesterchum will embed the image alongside the message in the log" + ) + self.label_trusteddomains_info.setWordWrap(True) + + def _on_button_trusteddomains_remove_pressed(): + selected_idx = self.list_trusteddomains.currentRow() + if selected_idx >= 0: + self.list_trusteddomains.takeItem(selected_idx) + + def _on_button_trusteddomains_add_pressed(): + # When "add" is pressed, open a dialog where the user can enter 1 domain + schema = {"label": "Domain:", "inputname": "value"} + + result = MultiTextDialog("ENTER DOMAIN", self, schema).getText() + if result is None: + return + domain = result["value"] + + if not "." in domain: + # No TLD (.com, .org, etc) + # not a valid domain + errbox = QtWidgets.QMessageBox(self) + errbox.setText("Not a valid domain!") + errbox.setInformativeText( + "You are missing the TLD (.com, .org, etc etc)" + ) + errbox.exec() + return + + if not (domain.startswith("https://") or domain.startswith("http://")): + # Missing protocol, but thats fine + # This also means you'd need two entries for http & https version of a website + # but we stan https everywhere in this house so we ball + domain = "https://" + domain + if domain.count("/") < 3: + # append a '/' to the end if there is no '/' anywhere after the TLD + # this is to prevent something like `https://example.org` to also match with `https://example.org.mynefariouswebsite.com' + # IE, 'https://example.org' becomes 'https://example.org/', but 'https://example.org/beap' is left as-is + domain += "/" + self.list_trusteddomains.addItem(domain) + + self.button_trusteddomains_add.clicked.connect( + _on_button_trusteddomains_add_pressed + ) + self.button_trusteddomains_remove.clicked.connect( + _on_button_trusteddomains_remove_pressed + ) + # Will add ability to turn off groups later # self.groupscheck = QtGui.QCheckBox("Use Groups", self) # self.groupscheck.setChecked(self.config.useGroups()) @@ -1587,6 +1644,12 @@ def reset_themeBox(): layout_chat.addWidget(self.animationscheck) layout_chat.addWidget(animateLabel) layout_chat.addWidget(self.randomscheck) + + layout_chat.addWidget(self.label_trusteddomains) + layout_chat.addWidget(self.list_trusteddomains) + layout_chat.addLayout(self.hbox_trusteddomains_buttons) + layout_chat.addWidget(self.label_trusteddomains_info) + # Re-enable these when it's possible to disable User and Memo links # layout_chat.addWidget(hr) # layout_chat.addWidget(QtGui.QLabel("User and Memo Links")) diff --git a/parsetools.py b/parsetools.py index bbe41e86..c61626fc 100644 --- a/parsetools.py +++ b/parsetools.py @@ -19,6 +19,8 @@ from pyquirks import PythonQuirks from scripts.services import BOTNAMES +import embeds + PchumLog = logging.getLogger("pchumLogger") # I'll clean up the things that are no longer needed once the transition is @@ -42,6 +44,12 @@ _alternian_begin = re.compile(r"") # Matches get set to alternian font _alternian_end = re.compile(r"") + +_embedre = re.compile( + r"(?i)(?:^|(?<=\s))(?:(?:https?):\/\/)[^\s]+(?:\.png|\.jpg|\.jpeg)(?:\?[^\s]+)?" +) + + quirkloader = ScriptQuirks() _functionre = None @@ -163,6 +171,33 @@ def convert(self, format): return self.string +class embedlink(lexercon.Chunk): + domain_trusted = False + + def __init__(self, url): + self.url = url + self.domain_trusted = embeds.manager.check_trustlist(self.url) + if self.domain_trusted: + embeds.manager.fetch_embed(url) + + def convert(self, format): + if format == "html": + # Only add the showable image tag if the domain is trusted & thus will actually be fetched + # Otherwise would show "fetching embed..." forever + if self.domain_trusted: + # tag to make it clickable, tag to make it show the image (actual image resource is added after parsing) + return ( + "%s
%s" + % (self.url, self.url, self.url, self.url, self.url) + ) + else: + return "%s" % (self.url, self.url) + elif format == "bbcode": + return f"[url]{self.url}[/url]" + else: + return self.url + + class hyperlink(lexercon.Chunk): def __init__(self, string): self.string = string @@ -214,6 +249,7 @@ def __init__(self, string, img): self.img = img def convert(self, format): + # raise Exception(format) if format == "html": return self.string elif format == "bbcode": @@ -258,6 +294,7 @@ def __init__(self, string): self.string = string def convert(self, format): + # print("SMILEY:: ", format) if format == "html": return "{}".format( smiledict[self.string], @@ -318,6 +355,7 @@ def lexMessage(string: str): # actually use it, save for Chumdroid...which shouldn't. # When I change out parsers, I might add it back in. ##(formatBegin, _format_begin), (formatEnd, _format_end), + (embedlink, _embedre), (imagelink, _imgre), (hyperlink, _urlre), (memolex, _memore), diff --git a/pesterchum.py b/pesterchum.py index a002b744..9425c53a 100755 --- a/pesterchum.py +++ b/pesterchum.py @@ -62,6 +62,7 @@ from randomer import RandomHandler, RANDNICK from toast import PesterToastMachine, PesterToast from scripts.services import SERVICES, CUSTOMBOTS, BOTNAMES, translate_nickserv_msg +import embeds try: from PyQt6 import QtCore, QtGui, QtWidgets, QtMultimedia @@ -1260,9 +1261,12 @@ def __init__(self, options, parent=None, app=None): # Part 1 :( try: if self.config.defaultprofile(): + # "defaultprofile" config setting is set + # load is here self.userprofile = userProfile(self.config.defaultprofile()) self.theme = self.userprofile.getTheme() else: + # Generate a new profile (likely this is the first-run) self.userprofile = userProfile( PesterProfile( "pesterClient%d" % (random.randint(100, 999)), @@ -1353,6 +1357,8 @@ def __init__(self, options, parent=None, app=None): self.move(100, 100) + embeds.manager.mainwindow = self ## We gotta get a reference to the user profile from somewhere since its not global. oh well + talk = QAction(self.theme["main/menus/client/talk"], self) self.talk = talk talk.triggered.connect(self.openChat) @@ -3096,6 +3102,15 @@ def updateOptions(self): self.config.set("time12Format", False) secondssetting = self.optionmenu.secondscheck.isChecked() self.config.set("showSeconds", secondssetting) + + # trusted domains + trusteddomains = [] + for i in range(self.optionmenu.list_trusteddomains.count()): + trusteddomains.append( + self.optionmenu.list_trusteddomains.item(i).text() + ) + self.userprofile.setTrustedDomains(trusteddomains) + # groups # groupssetting = self.optionmenu.groupscheck.isChecked() # self.config.set("useGroups", groupssetting) diff --git a/user_profile.py b/user_profile.py index a4aa387f..9d11d769 100644 --- a/user_profile.py +++ b/user_profile.py @@ -27,6 +27,19 @@ PchumLog = logging.getLogger("pchumLogger") +DEFAULT_EMBED_TRUSTLIST = [ # Default list of trusted image embed domains + "https://cdn.discordapp.com/", + "https://pesterchum.xyz/", + "https://i.imgur.com/", + "https://media1.tenor.com/", + "https://raw.githubusercontent.com/", + "https://gitlab.com/", + "https://i.giphy.com/", + "https://64.media.tumblr.com/", + "https://i.redd.it/", +] + + class PesterLog: def __init__(self, handle, parent=None): global _datadir @@ -256,10 +269,7 @@ def hideOfflineChums(self): return self.config.get("hideOfflineChums", False) def defaultprofile(self): - try: - return self.config["defaultprofile"] - except KeyError: - return None + return self.config.get("defaultprofile", None) def tabs(self): return self.config.get("tabs", True) @@ -652,6 +662,7 @@ def __init__(self, user): else: self.mentions = [] self.autojoins = [] + self.trusted_domains = DEFAULT_EMBED_TRUSTLIST else: # Trying to fix: # IOError: [Errno 2] @@ -720,6 +731,9 @@ def __init__(self, user): self.userprofile["autojoins"] = [] self.autojoins = self.userprofile["autojoins"] + if "trusteddomains" not in self.userprofile: + self.userprofile["trusteddomains"] = DEFAULT_EMBED_TRUSTLIST + self.trusted_domains = self.userprofile["trusteddomains"] try: with open(_datadir + "passwd.js") as fp: self.passwd = json.load(fp) @@ -821,6 +835,14 @@ def setAutoJoins(self, autojoins): self.userprofile["autojoins"] = self.autojoins self.save() + def getTrustedDomains(self): + return self.trusted_domains + + def setTrustedDomains(self, trusted_domains): + self.trusted_domains = trusted_domains + self.userprofile["trusteddomains"] = self.trusted_domains + self.save() + def save(self): handle = self.chat.handle if handle[0:12] == "pesterClient":