Skip to content

Commit 67c52f6

Browse files
committed
Harden OnJoin quote handling
1 parent 81068a0 commit 67c52f6

5 files changed

Lines changed: 192 additions & 50 deletions

File tree

__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
import sys
13+
from typing import Dict, List
1314

1415
if sys.version_info <= (3, 6):
1516
raise RuntimeError("This plugin requires Python 3.6 or above.")
@@ -22,11 +23,13 @@
2223
__version__ = "1.1.0"
2324

2425
# XXX Replace this with an appropriate author or supybot.Author instance.
25-
__author__ = supybot.Author("Barry Suridge", "Alcheri", "barry.suridge@outlook.com")
26+
__author__ = supybot.Author(
27+
"Barry Suridge", "Alcheri", "barry.suridge@outlook.com"
28+
)
2629

2730
# This is a dictionary mapping supybot.Author instances to lists of
2831
# contributions.
29-
__contributors__ = {}
32+
__contributors__: Dict[object, List[object]] = {}
3033

3134
# This is a url where the most recent plugin package can be downloaded.
3235
__url__ = "https://github.com/Alcheri/Plugins.git"
@@ -42,7 +45,7 @@
4245
# reloaded when this plugin is reloaded. Don't forget to import them as well!
4346

4447
if world.testing:
45-
from . import test
48+
from . import test as test
4649

4750
Class = plugin.Class
4851
configure = config.configure

config.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
from supybot.i18n import PluginInternationalization
1313

1414
_ = PluginInternationalization("OnJoin")
15-
except:
16-
_ = lambda x: x
15+
except ImportError:
1716

17+
def _(text):
18+
return text
1819

19-
def configure(advanced):
20-
from supybot.questions import expect, anything, something, yn
2120

21+
def configure(advanced):
2222
conf.registerPlugin("OnJoin", True)
2323

2424

@@ -43,7 +43,9 @@ def configure(advanced):
4343
)
4444

4545
conf.registerChannelValue(
46-
OnJoin, "enable", registry.Boolean(False, """Should plugin work in this channel?""")
46+
OnJoin,
47+
"enable",
48+
registry.Boolean(False, """Should plugin work in this channel?"""),
4749
)
4850

4951
# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:

plugin.py

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,98 @@
33
# All rights reserved.
44
#
55

6+
import os
67
import random
8+
import re
9+
import tempfile
10+
import threading
711
from pathlib import Path
812

913
import supybot.ircutils as utils
1014
import supybot.callbacks as callbacks
1115
from supybot.commands import optional, wrap
1216

1317
DEFAULT_RECENT_QUOTES = 5
18+
HARD_MAX_QUOTES = 2000
19+
HARD_RECENT_QUOTES = 25
20+
MAX_QUOTE_LENGTH = 360
21+
CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
1422

1523

1624
class OnJoin(callbacks.Plugin):
1725
"""Send a notice to all users entering a channel."""
1826

1927
public = False
2028

29+
def __init__(self, irc):
30+
super().__init__(irc)
31+
self._quote_lock = threading.Lock()
32+
2133
def _quotes_path(self):
2234
return Path(__file__).with_name("quotes.txt")
2335

2436
def _normalise_quote(self, text):
25-
quote = " ".join(text.splitlines()).strip()
37+
quote = " ".join(str(text).splitlines()).strip()
38+
quote = CONTROL_CHARS_RE.sub("", quote).strip()
39+
if len(quote) > MAX_QUOTE_LENGTH:
40+
quote = f"{quote[: MAX_QUOTE_LENGTH - 3]}..."
2641
return quote or None
2742

43+
def _max_stored_quotes(self):
44+
try:
45+
configured = self.registryValue("maxQuotes")
46+
except Exception:
47+
configured = HARD_MAX_QUOTES
48+
return min(max(configured, 1), HARD_MAX_QUOTES)
49+
50+
def _quote_file_lock(self):
51+
if not hasattr(self, "_quote_lock"):
52+
self._quote_lock = threading.Lock()
53+
return self._quote_lock
54+
2855
def _load_quotes(self):
2956
quotes_path = self._quotes_path()
57+
max_quotes = self._max_stored_quotes()
58+
quotes = []
3059
try:
3160
with quotes_path.open(encoding="utf-8") as quote_file:
32-
return [line.rstrip("\n") for line in quote_file if line.strip()]
61+
for line in quote_file:
62+
quote = self._normalise_quote(line)
63+
if quote is None:
64+
continue
65+
quotes.append(quote)
66+
if len(quotes) > max_quotes:
67+
quotes.pop(0)
3368
except OSError as err:
3469
self.log.warning("OnJoin: failed to read %s: %s", quotes_path, err)
3570
return None
71+
return quotes
3672

3773
def _write_quotes(self, quotes):
3874
quotes_path = self._quotes_path()
75+
tmp_name = None
3976
try:
40-
with quotes_path.open("w", encoding="utf-8") as quote_file:
77+
with tempfile.NamedTemporaryFile(
78+
"w",
79+
delete=False,
80+
dir=str(quotes_path.parent),
81+
encoding="utf-8",
82+
) as quote_file:
83+
tmp_name = quote_file.name
4184
for quote in quotes:
42-
quote_file.write(f"{quote}\n")
85+
normalised = self._normalise_quote(quote)
86+
if normalised:
87+
quote_file.write(f"{normalised}\n")
88+
os.replace(tmp_name, str(quotes_path))
4389
except OSError as err:
44-
self.log.warning("OnJoin: failed to write %s: %s", quotes_path, err)
90+
self.log.warning(
91+
"OnJoin: failed to write %s: %s", quotes_path, err
92+
)
93+
if tmp_name:
94+
try:
95+
os.unlink(tmp_name)
96+
except OSError:
97+
pass
4598
return False
4699
return True
47100

@@ -50,38 +103,41 @@ def _append_quote(self, text):
50103
if quote is None:
51104
return None
52105

53-
quotes = self._load_quotes()
54-
if quotes is None:
55-
return False
106+
with self._quote_file_lock():
107+
quotes = self._load_quotes()
108+
if quotes is None:
109+
return False
56110

57-
quotes.append(quote)
58-
max_quotes = self.registryValue("maxQuotes")
59-
if len(quotes) > max_quotes:
60-
quotes = quotes[-max_quotes:]
111+
quotes.append(quote)
112+
max_quotes = self._max_stored_quotes()
113+
if len(quotes) > max_quotes:
114+
quotes = quotes[-max_quotes:]
61115

62-
if not self._write_quotes(quotes):
63-
return False
116+
if not self._write_quotes(quotes):
117+
return False
64118
return quote
65119

66120
def _recent_quotes(self, count):
121+
count = min(count, HARD_RECENT_QUOTES)
67122
quotes = self._load_quotes()
68123
if quotes is None:
69124
return None
70125
start_index = max(len(quotes) - count, 0) + 1
71126
return list(enumerate(quotes[-count:], start=start_index))
72127

73128
def _delete_quote(self, quote_number):
74-
quotes = self._load_quotes()
75-
if quotes is None:
76-
return False
77-
78-
index = quote_number - 1
79-
if index < 0 or index >= len(quotes):
80-
return None
81-
82-
deleted_quote = quotes.pop(index)
83-
if not self._write_quotes(quotes):
84-
return False
129+
with self._quote_file_lock():
130+
quotes = self._load_quotes()
131+
if quotes is None:
132+
return False
133+
134+
index = quote_number - 1
135+
if index < 0 or index >= len(quotes):
136+
return None
137+
138+
deleted_quote = quotes.pop(index)
139+
if not self._write_quotes(quotes):
140+
return False
85141
return deleted_quote
86142

87143
def doJoin(self, irc, msg):
@@ -117,12 +173,12 @@ def _read_random_quote(self):
117173
if not line.strip():
118174
continue
119175
line_num += 1
120-
if random.uniform(0, line_num) < 1:
176+
if random.uniform(0, line_num) < 1: # nosec B311
121177
selected_line = line
122178
except OSError as err:
123179
self.log.warning("OnJoin: failed to read %s: %s", quotes_path, err)
124180
return None
125-
return selected_line or None
181+
return self._normalise_quote(selected_line) if selected_line else None
126182

127183
def addquote(self, irc, msg, args, text):
128184
"""<text>
@@ -150,7 +206,7 @@ def recentquotes(self, irc, msg, args, count):
150206

151207
max_count = self.registryValue("maxRecentQuotes")
152208
quote_count = count or DEFAULT_RECENT_QUOTES
153-
quote_count = min(quote_count, max_count)
209+
quote_count = min(quote_count, max_count, HARD_RECENT_QUOTES)
154210

155211
quotes = self._recent_quotes(quote_count)
156212
if quotes is None:
@@ -161,7 +217,11 @@ def recentquotes(self, irc, msg, args, count):
161217
return
162218

163219
for quote_number, quote in reversed(quotes):
164-
irc.reply(f"{quote_number}. {quote}", notice=True, private=True)
220+
irc.reply(
221+
self._normalise_quote(f"{quote_number}. {quote}"),
222+
notice=True,
223+
private=True,
224+
)
165225

166226
recentquotes = wrap(
167227
recentquotes,

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ extend-exclude = '''
3434
)/
3535
'''
3636

37+
[tool.pytest.ini_options]
38+
python_files = ["test.py"]
39+
40+
[tool.mypy]
41+
python_version = "3.10"
42+
ignore_missing_imports = true
43+
explicit_package_bases = true
44+
exclude = "(__init__|test)\\.py"
45+
3746
[tool.setuptools.package-dir]
3847
"limnoria_onjoin" = "."
3948
"limnoria_onjoin.local" = "local/"

0 commit comments

Comments
 (0)