Skip to content

Commit

Permalink
feat(printing): add rate limit to print job requests (closes #662)
Browse files Browse the repository at this point in the history
  • Loading branch information
aarushtools authored and alanzhu0 committed Jul 7, 2024
1 parent 513dd8f commit aa8e402
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 21 deletions.
87 changes: 70 additions & 17 deletions intranet/apps/printing/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,38 @@


class InvalidInputPrintingError(Exception):
"""An error occurred while printing, but it was due to invalid input from the user and is not worthy of a ``CRITICAL`` log message."""
"""An error occurred while printing, but it was due to invalid input from the user and is not worthy of a
``CRITICAL`` log message."""


class RatelimitCacheError(Exception):
"""An error occurred while accessing the cache to rate limit a user"""


class RatelimitExceededError(Exception):
"""An error occurred because the user exceeded the printing rate limit"""


def get_user_ratelimit_status(username: str) -> bool:
cache_key = f"printing_ratelimit:{username}"
value = cache.get(cache_key, None)
if value is None or value < settings.PRINT_RATELIMIT_FREQUENCY:
# User did not go over the rate limit
return False
elif value >= settings.PRINT_RATELIMIT_FREQUENCY:
return True
else:
raise RatelimitCacheError("An error occurred while trying to get your rate limit status")


def set_user_ratelimit_status(username: str) -> None:
cache_key = f"printing_ratelimit:{username}"
value = cache.get(cache_key, None)
if value is None:
# Set the key to expire in the time specified by settings and indicate the user has requested once so far
cache.set(cache_key, 1, settings.PRINT_RATELIMIT_MINUTES * 60)
elif value >= 1:
cache.incr(cache_key)


def get_printers() -> Dict[str, str]:
Expand Down Expand Up @@ -72,15 +103,15 @@ def get_printers() -> Dict[str, str]:
# Record the name of the printer so when we parse the rest of the
# extended description we know which printer it's referring to.
last_name = name
elif last_name is not None:
match = DESCRIPTION_LINE_RE.match(line)
if match is not None:
# Pull out the description
description = match.group(1)
# And make sure we don't set an empty description
if description:
printers[last_name] = description
last_name = None
elif last_name is not None:
match = DESCRIPTION_LINE_RE.match(line)
if match is not None:
# Pull out the description
description = match.group(1)
# And make sure we don't set an empty description
if description:
printers[last_name] = description
last_name = None

cache.set(key, printers, timeout=settings.CACHE_AGE["printers_list"])
return printers
Expand Down Expand Up @@ -153,14 +184,14 @@ def get_numpages(tmpfile_name: str) -> int:
return num_pages


# If a file is identified as a mimetype that is a key in this dictionary, the magic files (in the "magic_files" director) from the corresponding list
# will be used to re-examine the file and attempt to find a better match.
# Why not just always use those files? Well, if you give libmagic a list of files, it will check *only* the files you tell it to, excluding the
# system-wide magic database. Worse, there is no reliable method of getting the system-wide database path (which is distro-specific, so we can't just
# hardcode it). This really is the best solution.
# If a file is identified as a mimetype that is a key in this dictionary, the magic files (in the "magic_files"
# director) from the corresponding list will be used to re-examine the file and attempt to find a better match. Why
# not just always use those files? Well, if you give libmagic a list of files, it will check *only* the files you
# tell it to, excluding the system-wide magic database. Worse, there is no reliable method of getting the system-wide
# database path (which is distro-specific, so we can't just hardcode it). This really is the best solution.
EXTRA_MAGIC_FILES = {"application/zip": ["msooxml"]}
# If the re-examination of a file with EXTRA_MAGIC_FILES yields one of these mimetypes, the original mimetype (the one that prompted re-examining
# based on EXTRA_MAGIC_FILES) will be used instead.
# If the re-examination of a file with EXTRA_MAGIC_FILES yields one of these mimetypes, the original mimetype (the
# one that prompted re-examining based on EXTRA_MAGIC_FILES) will be used instead.
GENERIC_MIMETYPES = {"application/octet-stream"}


Expand Down Expand Up @@ -356,6 +387,27 @@ def print_job(obj: PrintJob, do_print: bool = True):
f"This file contains {num_pages} pages. You may only print up to {settings.PRINTING_PAGES_LIMIT} pages using this tool."
)

elif num_pages > settings.PRINTING_PAGES_LIMIT_STUDENTS:
raise InvalidInputPrintingError(
f"This file contains {num_pages} pages. " f"You may only print up to {settings.PRINTING_PAGES_LIMIT_STUDENTS} pages using this tool."
)

if get_user_ratelimit_status(obj.user.username):
# Bypass rate limit for admins but still send error message for debugging purposes
if obj.user.is_printing_admin:
logger.debug(
"""Administrator %s passed the rate limit of %s print jobs every %s minutes, but since they are an
administrator the request will still go through.""",
obj.user.username,
settings.PRINT_RATELIMIT_FREQUENCY,
settings.PRINT_RATELIMIT_MINUTES,
)
# If user needs to be rate limited
elif not obj.user.is_printing_admin and not obj.user.is_teacher: # Don't rate limit teachers
raise RatelimitExceededError(
f"You're sending print jobs too fast! You can only send {settings.PRINT_RATELIMIT_FREQUENCY} print "
f"jobs every {settings.PRINT_RATELIMIT_MINUTES} minutes."
)
if do_print:
args = ["lpr", "-P", printer, final_filename]

Expand Down Expand Up @@ -394,6 +446,7 @@ def print_job(obj: PrintJob, do_print: bool = True):
raise Exception(f"An error occurred while printing your file: {e.output.strip()}") from e

obj.printed = True
set_user_ratelimit_status(obj.user.username)
obj.save()
finally:
for filename in delete_filenames:
Expand Down
2 changes: 1 addition & 1 deletion intranet/apps/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ def is_eighth_admin(self) -> bool:
return self.has_admin_permission("eighth")

@property
def has_print_permission(self) -> bool:
def is_printing_admin(self) -> bool:
"""Checks if user has the admin permission 'printing'.
Returns:
Expand Down
7 changes: 6 additions & 1 deletion intranet/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
# The maximum number of pages in one document that can be
# printed through the printing functionality (determined through pdfinfo)
# even number preferred to allow for maximum utilization of duplex printing
PRINTING_PAGES_LIMIT = 16
PRINTING_PAGES_LIMIT_STUDENTS = 16
PRINTING_PAGES_LIMIT_TEACHERS = 50

# The rate limit for print job requests (2 requests every 5 minutes)
PRINT_RATELIMIT_FREQUENCY = 2
PRINT_RATELIMIT_MINUTES = 5

# The maximum file upload and download size for files
FILES_MAX_UPLOAD_SIZE = 200 * 1024 * 1024
Expand Down
2 changes: 1 addition & 1 deletion intranet/templates/nav.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
</a>
</li>

{% if is_tj_ip or request.user.has_print_permission %}
{% if is_tj_ip or request.user.is_printing_admin %}
<li {% if nav_category == "printing" %}class="selected"{% endif %}>
<a href="{% url 'printing' %}">
<i class="nav-icon print-icon"></i>
Expand Down
9 changes: 8 additions & 1 deletion intranet/templates/printing/print_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@
<br>
<b>The following restrictions apply:</b>
<ul>
<li>Print jobs can have a maximum of {{ DJANGO_SETTINGS.PRINTING_PAGES_LIMIT }} pages.</li>
{% if user.is_teacher or user.is_printing_admin %}
<li>Print jobs can have a maximum of {{ DJANGO_SETTINGS.PRINTING_PAGES_LIMIT_TEACHERS }} pages.</li>
{% else %}
<li>Print jobs can have a maximum of {{ DJANGO_SETTINGS.PRINTING_PAGES_LIMIT_STUDENTS }} pages.</li>
{% endif %}
{% if not user.is_teacher and not user.is_printing_admin %}
<li>You may send up to {{ DJANGO_SETTINGS.PRINT_RATELIMIT_FREQUENCY}} print jobs every {{ DJANGO_SETTINGS.PRINT_RATELIMIT_MINUTES }} minutes.</li>
{% endif %}
<li>If the printer doesn't appear to be working, do <b>NOT</b> keep attempting to print. Instead, contact the Student Systems Administrators with <a href="{% url 'send_feedback' %}">the Ion feedback form</a>.</li>
<li>Please be mindful of other printing users.</li>
<li>Do not print documents that use an excessive amount of ink (e.g. dark backgrounds).</li>
Expand Down

0 comments on commit aa8e402

Please sign in to comment.