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
"
+ % (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":