commit 08b3d457c067ebe84c14aaadb7f8bc0958fae1b8 Author: Frédéric Péters Date: Tue Aug 13 12:21:57 2019 +0200 Import Upstream version 0.4.0+8+gd58c489 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c47828c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +*.swp +dist +*.egg-info +docs/_build/* +.tox/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2ca5d4f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: python +env: + - DJANGO_VERSION=1.4 + - DJANGO_VERSION=1.5 + - DJANGO_VERSION=1.6 +python: + - "2.6" + - "2.7" +install: + - pip install -q "Django>=${DJANGO_VERSION},<${DJANGO_VERSION}.99" +script: ./run.sh test +matrix: + include: + - python: 3.3 + env: DJANGO_VERSION=1.5 + - python: 3.3 + env: DJANGO_VERSION=1.6 diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..d02361f --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,29 @@ +========== +Change Log +========== + +v0.3 +==== + +- Drop the 'Backend' concept. +- Add settings: RATELIMIT_USE_CACHE and RATELIMIT_CACHE_PREFIX. +- Allow custom key functions. +- Tests with Django 1.4.x and 1.5.x. +- Refactor to simplify tests and development requirements. + +v0.2 +==== + +- Added real docs. +- Fix unicode field values. +- Add real tests. +- Use the Ratelimited exception, RatelimitMiddleware, and + RATELIMIT_VIEW setting. +- Add RATELIMIT_ENABLE setting. +- Add the skip_if argument. +- Always add request.limited. + +v0.1 +==== + +- Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..155fc8e --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2013, James Socol + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..8ad34cc --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include CHANGELOG +include LICENSE +include MANIFEST.in +include README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..9e388e5 --- /dev/null +++ b/README.rst @@ -0,0 +1,15 @@ +================ +Django Ratelimit +================ + +Django Ratelimit provides a decorator to rate-limit views. Limiting can +be based on IP address or a field in the request--either a GET or POST +variable. + +.. image:: https://travis-ci.org/jsocol/django-ratelimit.png?branch=master + :target: https://travis-ci.org/jsocol/django-ratelimit + +:Code: https://github.com/jsocol/django-ratelimit +:License: BSD; see LICENSE file +:Issues: https://github.com/jsocol/django-ratelimit/issues +:Documentation: http://django-ratelimit.readthedocs.org/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..a602a25 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoRatelimit.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoRatelimit.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoRatelimit" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoRatelimit" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..b3c59a2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# +# Django Ratelimit documentation build configuration file, created by +# sphinx-quickstart on Fri Jan 4 15:55:31 2013. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Django Ratelimit' +copyright = u'2013, James Socol' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.3' +# The full version, including alpha/beta/rc tags. +release = '0.3.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'DjangoRatelimitdoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'DjangoRatelimit.tex', u'Django Ratelimit Documentation', + u'James Socol', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'djangoratelimit', u'Django Ratelimit Documentation', + [u'James Socol'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'DjangoRatelimit', u'Django Ratelimit Documentation', + u'James Socol', 'DjangoRatelimit', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..e1ee72d --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,45 @@ +.. _contributing-chapter: + +============ +Contributing +============ + + +Set Up +====== + +Create a virtualenv_ and install Django with pip_:: + + $ pip install Django + + +Running the Tests +================= + +Running the tests is as easy as:: + + $ ./run.sh test + +You may also run the test on multiple versions of Django using tox. + +- First install tox:: + + $ pip install tox + +- Then run the tests with tox:: + + $ tox + + +Code Standards +============== + +I ask two things for pull requests. + +* The flake8_ tool must not report any violations. +* All tests, including new tests where appropriate, must pass. + + +.. _virtualenv: http://www.virtualenv.org/en/latest/ +.. _pip: http://www.pip-installer.org/en/latest/ +.. _flake8: https://pypi.python.org/pypi/flake8 diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..7d41a3f --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,60 @@ +================ +Django Ratelimit +================ + +Project +======= + +**Django Ratelimit** is a ratelimiting decorator for Django views. + +.. image:: https://travis-ci.org/jsocol/django-ratelimit.png?branch=master + :target: https://travis-ci.org/jsocol/django-ratelimit + +:Code: https://github.com/jsocol/django-ratelimit +:License: Apache Software License +:Issues: https://github.com/jsocol/django-ratelimit/issues +:Documentation: http://django-ratelimit.readthedocs.org/ + + +Quickstart +========== + +Install:: + + pip install django-ratelimit + + +Use as a decorator in ``views.py``:: + + from ratelimit.decorators import ratelimit + + @ratelimit() + def myview(request): + # ... + + @ratelimit(rate='100/h') + def secondview(request): + # ... + + +.. _PyPI: http://pypi.python.org/pypi/django-ratelimit + + +Contents +======== + +.. toctree:: + :maxdepth: 2 + + settings + usage + contributing + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 0000000..c22bc31 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,17 @@ +.. _settings-chapter: + +======== +Settings +======== + +``RATELIMIT_CACHE_PREFIX``: + An optional cache prefix for ratelimit keys (in addition to the + ``PREFIX`` value). *rl:* +``RATELIMIT_ENABLE``: + Set to ``False`` to disable rate-limiting across the board. *True* +``RATELIMIT_USE_CACHE``: + Which cache (from the ``CACHES`` dict) to use. *default* +``RATELIMIT_VIEW``: + A view to use when a request is ratelimited, in conjunction with + ``RatelimitMiddleware``. (E.g.: ``'myapp.views.ratelimited'``.) + *None* diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..9dc458d --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,205 @@ +.. _usage-chapter: + +====================== +Using Django Ratelimit +====================== + + +Use as a decorator +================== + +The ``@ratelimit`` view decorator provides several optional arguments +with sensible defaults (in italics). + +Import:: + + from ratelimit.decorators import ratelimit + + +.. py:decorator:: ratelimit(ip=True, block=False, method=None, field=None, rate='5/m', skip_if=None, keys=None) + + :arg ip: + *True* Whether to rate-limit based on the IP from ``REMOTE_ADDR``. + + .. Note:: + + If you're using a reverse proxy, set this to False and use + the ``keys`` argument. + + :arg block: + *False* Whether to block the request instead of annotating. + + :arg method: + *None* Which HTTP method(s) to rate-limit. May be a string, a + list/tuple, or ``None`` for all methods. + + :arg field: + *None* Which HTTP GET/POST argument field(s) to use to + rate-limit. May be a string or a list of strings. + + :arg rate: + *'5/m'* The number of requests per unit time allowed. Valid units are: + + * ``s`` - seconds + * ``m`` - minutes + * ``h`` - hours + * ``d`` - days + + :arg skip_if: + *None* If specified, pass this parameter a callable + (e.g. lambda function) that takes the current request. If the + callable returns a value that evaluates to True, the rate + limiting is skipped for that particular view. This is useful + to do things like selectively deactivating rate limiting based + on a value in your settings file, or based on an attirbute in + the current request object. (Also see the ``RATELIMIT_ENABLE`` + setting below.) + + :arg keys: + *None* Specify a function or list of functions that take the + request object and return string keys. This allows you to + define custom logic (for example, use an authenticated user ID + or unauthenticated IP address). + + .. Note:: + + If you're using a reverse proxy, pass in a function that + pulls the appropriate field from ``request.META`` for the + actual ip address of the client. + + +Examples:: + + @ratelimit() + def myview(request): + # Will be true if the same IP makes more than 5 requests/minute. + was_limited = getattr(request, 'limited', False) + return HttpResponse() + + @ratelimit(block=True) + def myview(request): + # If the same IP makes >5 reqs/min, will raise Ratelimited + return HttpResponse() + + @ratelimit(field='username') + def login(request): + # If the same username OR IP is used >5 times/min, this will be True. + # The `username` value will come from GET or POST, determined by the + # request method. + was_limited = getattr(request, 'limited', False) + return HttpResponse() + + @ratelimit(method='POST') + def login(request): + # Only apply rate-limiting to POSTs. + return HttpResponseRedirect() + + @ratelimit(field=['username', 'other_field']) + def login(request): + # Use multiple field values. + return HttpResponse() + + @ratelimit(rate='4/h') + def slow(request): + # Allow 4 reqs/hour. + return HttpResponse() + + @ratelimit(skip_if=lambda request: getattr(request, 'some_attribute', False)) + def skipif1(request): + # Conditionally skip rate limiting (example 1) + return HttpResponse() + + @ratelimit(skip_if=lambda request: settings.MYAPP_DEACTIVATE_RATE_LIMITING) + def skipif2(request): + # Conditionally skip rate limiting (example 2) + return HttpResponse() + + @ratelimit(keys=lambda x: 'min', rate='1/m') + @ratelimit(keys=lambda x: 'hour', rate='10/h') + @ratelimit(keys=lambda x: 'day', rate='50/d') + def post(request): + # Stack them. + # Note: once a decorator limits the request, the ones after + # won't count the request for limiting. + return HttpResponse() + + @ratelimit(ip=False, + keys=lambda req: req.META.get('HTTP_X_CLUSTER_CLIENT_IP', + req.META['REMOTE_ADDR'])) + def post(request): + # This will use the HTTP_X_CLUSTER_CLIENT_IP and default to + # REMOTE_ADDR if that's not set. This is how you'd set up your + # rate limiting if you're behind a reverse proxy. + # + # It's important to set ip to False here. Otherwise it'll use + # limit on EITHER HTTP_X_CLUSTER_CLIENT_IP or REMOTE_ADDR and + # the end result is that everything will be throttled. + return HttpResponse() + + +Helper Function +=============== + +In some cases the decorator is not flexible enough. If this is an +issue you use the ``is_ratelimited`` helper function. It's similar to +the decorator. + +Import:: + + from ratelimit.helpers import is_ratelimited + + +.. py:function:: is_ratelimited(request, increment=False, ip=True, method=None, field=None, rate='5/m', keys=None) + + :arg request: + (Required) The request object. + + :arg increment: + *False* Whether to increment the count. + + :arg ip: + *True* Whether to rate-limit based on the IP. + + :arg method: + *None* Which HTTP method(s) to rate-limit. May be a string, a + list/tuple, or ``None`` for all methods. + + :arg field: + *None* Which HTTP field(s) to use to rate-limit. May be a + string or a list. + + :arg rate: + *'5/m'* The number of requests per unit time allowed. + + :arg keys: + *None* Specify a function or list of functions that take the + request object and return string keys. This allows you to + define custom logic (for example, use an authenticated user ID + or unauthenticated IP address). + + +Exceptions +========== + +.. py:class:: ratelimit.exceptions.Ratelimited + + If a request is ratelimited and ``block`` is set to ``True``, + Ratelimit will raise ``ratelimit.exceptions.Ratelimited``. + + This is a subclass of Django's ``PermissionDenied`` exception, so + if you don't need any special handling beyond the built-in 403 + processing, you don't have to do anything. + + +Middleware +========== + +There is optional middleware to use a custom view to handle ``Ratelimited`` +exceptions. + +To use it, add ``ratelimit.middleware.RatelimitMiddleware`` to your +``MIDDLEWARE_CLASSES`` (toward the bottom of the list) and set +``RATELIMIT_VIEW`` to the full path of a view you want to use. + +The view specified in ``RATELIMIT_VIEW`` will get two arguments, the +``request`` object (after ratelimit processing) and the exception. diff --git a/ratelimit/__init__.py b/ratelimit/__init__.py new file mode 100644 index 0000000..1300a0b --- /dev/null +++ b/ratelimit/__init__.py @@ -0,0 +1,2 @@ +VERSION = (0, 4, 0) +__version__ = '.'.join(map(str, VERSION)) diff --git a/ratelimit/decorators.py b/ratelimit/decorators.py new file mode 100644 index 0000000..cd4ac32 --- /dev/null +++ b/ratelimit/decorators.py @@ -0,0 +1,24 @@ +from functools import wraps + +from ratelimit.exceptions import Ratelimited +from ratelimit.helpers import is_ratelimited + + +__all__ = ['ratelimit'] + + +def ratelimit(ip=True, block=False, method=['POST'], field=None, rate='5/m', + skip_if=None, keys=None): + def decorator(fn): + @wraps(fn) + def _wrapped(request, *args, **kw): + request.limited = getattr(request, 'limited', False) + if skip_if is None or not skip_if(request): + ratelimited = is_ratelimited(request=request, increment=True, + ip=ip, method=method, field=field, + rate=rate, keys=keys) + if ratelimited and block: + raise Ratelimited() + return fn(request, *args, **kw) + return _wrapped + return decorator diff --git a/ratelimit/exceptions.py b/ratelimit/exceptions.py new file mode 100644 index 0000000..f39a0f4 --- /dev/null +++ b/ratelimit/exceptions.py @@ -0,0 +1,5 @@ +from django.core.exceptions import PermissionDenied + + +class Ratelimited(PermissionDenied): + pass diff --git a/ratelimit/helpers.py b/ratelimit/helpers.py new file mode 100644 index 0000000..45cc6a6 --- /dev/null +++ b/ratelimit/helpers.py @@ -0,0 +1,99 @@ +import hashlib +import re + +from django.conf import settings +from django.core.cache import get_cache + + +__all__ = ['is_ratelimited'] + +RATELIMIT_ENABLE = getattr(settings, 'RATELIMIT_ENABLE', True) +CACHE_PREFIX = getattr(settings, 'RATELIMIT_CACHE_PREFIX', 'rl:') + +_PERIODS = { + 's': 1, + 'm': 60, + 'h': 60 * 60, + 'd': 24 * 60 * 60, +} + +rate_re = re.compile('([\d]+)/([\d]*)([smhd])') + + +def _method_match(request, method=None): + if method is None: + return True + if not isinstance(method, (list, tuple)): + method = [method] + return request.method in [m.upper() for m in method] + + +def _split_rate(rate): + count, multi, period = rate_re.match(rate).groups() + count = int(count) + time = _PERIODS[period.lower()] + if multi: + time = time * int(multi) + return count, time + + +def _get_keys(request, ip=True, field=None, keyfuncs=None): + keys = [] + if ip: + keys.append('ip:' + request.META['REMOTE_ADDR']) + if field is not None: + if not isinstance(field, (list, tuple)): + field = [field] + for f in field: + val = getattr(request, request.method).get(f, '').encode('utf-8') + val = hashlib.sha1(val).hexdigest() + keys.append(u'field:%s:%s' % (f, val)) + if keyfuncs: + if not isinstance(keyfuncs, (list, tuple)): + keyfuncs = [keyfuncs] + for k in keyfuncs: + keys.append(k(request)) + return [CACHE_PREFIX + k for k in keys] + + +def _incr(cache, keys, timeout=60): + # Yes, this is a race condition, but memcached.incr doesn't reset the + # timeout. + counts = cache.get_many(keys) + for key in keys: + if key in counts: + counts[key] += 1 + else: + counts[key] = 1 + cache.set_many(counts, timeout=timeout) + return counts + + +def _get(cache, keys): + counts = cache.get_many(keys) + for key in keys: + if key in counts: + counts[key] += 1 + else: + counts[key] = 1 + return counts + + +def is_ratelimited(request, increment=False, ip=True, method=['POST'], + field=None, rate='5/m', keys=None): + count, period = _split_rate(rate) + cache = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') + cache = get_cache(cache) + + request.limited = getattr(request, 'limited', False) + if (not request.limited and RATELIMIT_ENABLE and + _method_match(request, method)): + _keys = _get_keys(request, ip, field, keys) + if increment: + counts = _incr(cache, _keys, period) + else: + counts = _get(cache, _keys) + if any([c > count for c in counts.values()]): + request.limited = True + + return request.limited diff --git a/ratelimit/middleware.py b/ratelimit/middleware.py new file mode 100644 index 0000000..4ca7a88 --- /dev/null +++ b/ratelimit/middleware.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.utils.importlib import import_module + +from ratelimit.exceptions import Ratelimited + + +class RatelimitMiddleware(object): + def process_exception(self, request, exception): + if not isinstance(exception, Ratelimited): + return + module_name, _, view_name = settings.RATELIMIT_VIEW.rpartition('.') + module = import_module(module_name) + view = getattr(module, view_name) + return view(request, exception) diff --git a/ratelimit/mixins.py b/ratelimit/mixins.py new file mode 100644 index 0000000..a8dc515 --- /dev/null +++ b/ratelimit/mixins.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +from .decorators import ratelimit + + +class RateLimitMixin(object): + """ + Mixin for usage in Class Based Views + configured with the decorator ``ratelimit`` defaults. + + Configure the class-attributes prefixed with ``ratelimit_`` + for customization of the ratelimit process. + + Example:: + + class ContactView(RateLimitMixin, FormView): + form_class = ContactForm + template_name = "contact.html" + + ratelimit_block = True + + def form_valid(self, form): + # do sth. here + return super(ContactView, self).form_valid(form) + + """ + ratelimit_ip = True + ratelimit_block = False + ratelimit_method = ['POST'] + ratelimit_field = None + ratelimit_rate = '5/m' + ratelimit_skip_if = None + ratelimit_keys = None + + def get_ratelimit_config(self): + return dict( + (k[len("ratelimit_"):], v) + for k, v in vars(self.__class__).items() + if k.startswith("ratelimit") + ) + + def dispatch(self, *args, **kwargs): + return ratelimit( + **self.get_ratelimit_config() + )(super(RateLimitMixin, self).dispatch)(*args, **kwargs) diff --git a/ratelimit/models.py b/ratelimit/models.py new file mode 100644 index 0000000..29244d0 --- /dev/null +++ b/ratelimit/models.py @@ -0,0 +1 @@ +# This module intentionally left blank. diff --git a/ratelimit/tests.py b/ratelimit/tests.py new file mode 100644 index 0000000..7585e81 --- /dev/null +++ b/ratelimit/tests.py @@ -0,0 +1,429 @@ +import django +from django.core.cache import cache, InvalidCacheBackendError +from django.test import RequestFactory, TestCase +from django.test.utils import override_settings +from django.views.generic import View + +from ratelimit.decorators import ratelimit +from ratelimit.exceptions import Ratelimited +from ratelimit.mixins import RateLimitMixin +from ratelimit.helpers import is_ratelimited + + +class RatelimitTests(TestCase): + def setUp(self): + cache.clear() + + def test_limit_ip(self): + @ratelimit(ip=True, method=None, rate='1/m', block=True) + def view(request): + return True + + req = RequestFactory().get('/') + assert view(req), 'First request works.' + with self.assertRaises(Ratelimited): + view(req) + + def test_block(self): + @ratelimit(ip=True, method=None, rate='1/m', block=True) + def blocked(request): + return request.limited + + @ratelimit(ip=True, method=None, rate='1/m', block=False) + def unblocked(request): + return request.limited + + req = RequestFactory().get('/') + + assert not blocked(req), 'First request works.' + with self.assertRaises(Ratelimited): + blocked(req) + + assert unblocked(req), 'Request is limited but not blocked.' + + def test_method(self): + rf = RequestFactory() + post = rf.post('/') + get = rf.get('/') + + @ratelimit(ip=True, method=['POST'], rate='1/m') + def limit_post(request): + return request.limited + + @ratelimit(ip=True, method=['POST', 'GET'], rate='1/m') + def limit_get(request): + return request.limited + + assert not limit_post(post), 'Do not limit first POST.' + assert limit_post(post), 'Limit second POST.' + assert not limit_post(get), 'Do not limit GET.' + + assert limit_get(post), 'Limit first POST.' + assert limit_get(get), 'Limit first GET.' + + def test_field(self): + james = RequestFactory().post('/', {'username': 'james'}) + john = RequestFactory().post('/', {'username': 'john'}) + + @ratelimit(ip=False, field='username', rate='1/m') + def username(request): + return request.limited + + assert not username(james), "james' first request is fine." + assert username(james), "james' second request is limited." + assert not username(john), "john's first request is fine." + + def test_field_unicode(self): + post = RequestFactory().post('/', {'username': u'fran\xe7ois'}) + + @ratelimit(ip=False, field='username', rate='1/m') + def view(request): + return request.limited + + assert not view(post), 'First request is not limited.' + assert view(post), 'Second request is limited.' + + def test_field_empty(self): + post = RequestFactory().post('/', {}) + + @ratelimit(ip=False, field='username', rate='1/m') + def view(request): + return request.limited + + assert not view(post), 'First request is not limited.' + assert view(post), 'Second request is limited.' + + def test_rate(self): + req = RequestFactory().post('/') + + @ratelimit(ip=True, rate='2/m') + def twice(request): + return request.limited + + assert not twice(req), 'First request is not limited.' + assert not twice(req), 'Second request is not limited.' + assert twice(req), 'Third request is limited.' + + def test_skip_if(self): + req = RequestFactory().post('/') + + @ratelimit(rate='1/m', skip_if=lambda r: getattr(r, 'skip', False)) + def view(request): + return request.limited + + assert not view(req), 'First request is not limited.' + assert view(req), 'Second request is limited.' + del req.limited + req.skip = True + assert not view(req), 'Skipped request is not limited.' + + @override_settings(RATELIMIT_USE_CACHE='fake.cache') + def test_bad_cache(self): + """The RATELIMIT_USE_CACHE setting works if the cache exists.""" + + @ratelimit() + def view(request): + return request + + req = RequestFactory().post('/') + + with self.assertRaises(InvalidCacheBackendError): + view(req) + + def test_keys(self): + """Allow custom functions to set cache keys.""" + class User(object): + def __init__(self, authenticated=False): + self.pk = 1 + self.authenticated = authenticated + + def is_authenticated(self): + return self.authenticated + + def user_or_ip(req): + if req.user.is_authenticated(): + return 'uip:%d' % req.user.pk + return 'uip:%s' % req.META['REMOTE_ADDR'] + + @ratelimit(ip=False, rate='1/m', block=False, keys=user_or_ip) + def view(request): + return request.limited + + req = RequestFactory().post('/') + req.user = User(authenticated=False) + + assert not view(req), 'First unauthenticated request is allowed.' + assert view(req), 'Second unauthenticated request is limited.' + + del req.limited + req.user = User(authenticated=True) + + assert not view(req), 'First authenticated request is allowed.' + assert view(req), 'Second authenticated is limited.' + + def test_stacked_decorator(self): + """Allow @ratelimit to be stacked.""" + # Put the shorter one first and make sure the second one doesn't + # reset request.limited back to False. + @ratelimit(ip=False, rate='1/m', block=False, keys=lambda x: 'min') + @ratelimit(ip=False, rate='10/d', block=False, keys=lambda x: 'day') + def view(request): + return request.limited + + req = RequestFactory().post('/') + assert not view(req), 'First unauthenticated request is allowed.' + assert view(req), 'Second unauthenticated request is limited.' + + def test_is_ratelimited(self): + def get_keys(request): + return 'test_is_ratelimited_key' + + def not_increment(request): + return is_ratelimited(request, increment=False, ip=False, + method=None, keys=[get_keys], rate='1/m') + + def do_increment(request): + return is_ratelimited(request, increment=True, ip=False, + method=None, keys=[get_keys], rate='1/m') + + req = RequestFactory().get('/') + # Does not increment. Count still 0. Does not rate limit + # because 0 < 1. + assert not not_increment(req), 'Request should not be rate limited.' + + # Increments. Does not rate limit because 0 < 1. Count now 1. + assert not do_increment(req), 'Request should not be rate limited.' + + # Does not increment. Count still 1. Rate limits because 1 < 1 + # is false. + assert not_increment(req), 'Request should be rate limited.' + + +#do it here, since python < 2.7 does not have unittest.skipIf +if django.VERSION >= (1, 4): + class RateLimitCBVTests(TestCase): + + SKIP_REASON = u'Class Based View supported by Django >=1.4' + + def setUp(self): + cache.clear() + + def test_limit_ip(self): + + class RLView(RateLimitMixin, View): + ratelimit_ip = True + ratelimit_method = None + ratelimit_rate = '1/m' + ratelimit_block = True + + rlview = RLView.as_view() + + req = RequestFactory().get('/') + assert rlview(req), 'First request works.' + with self.assertRaises(Ratelimited): + rlview(req) + + def test_block(self): + + class BlockedView(RateLimitMixin, View): + ratelimit_ip = True + ratelimit_method = None + ratelimit_rate = '1/m' + ratelimit_block = True + + def get(self, request, *args, **kwargs): + return request.limited + + class UnBlockedView(RateLimitMixin, View): + ratelimit_ip = True + ratelimit_method = None + ratelimit_rate = '1/m' + ratelimit_block = False + + def get(self, request, *args, **kwargs): + return request.limited + + blocked = BlockedView.as_view() + unblocked = UnBlockedView.as_view() + + req = RequestFactory().get('/') + + assert not blocked(req), 'First request works.' + with self.assertRaises(Ratelimited): + blocked(req) + + assert unblocked(req), 'Request is limited but not blocked.' + + def test_method(self): + rf = RequestFactory() + post = rf.post('/') + get = rf.get('/') + + class LimitPostView(RateLimitMixin, View): + ratelimit_ip = True + ratelimit_method = ['POST'] + ratelimit_rate = '1/m' + + def post(self, request, *args, **kwargs): + return request.limited + get = post + + class LimitGetView(RateLimitMixin, View): + ratelimit_ip = True + ratelimit_method = ['POST', 'GET'] + ratelimit_rate = '1/m' + + def post(self, request, *args, **kwargs): + return request.limited + get = post + + limit_post = LimitPostView.as_view() + limit_get = LimitGetView.as_view() + + assert not limit_post(post), 'Do not limit first POST.' + assert limit_post(post), 'Limit second POST.' + assert not limit_post(get), 'Do not limit GET.' + + assert limit_get(post), 'Limit first POST.' + assert limit_get(get), 'Limit first GET.' + + def test_field(self): + james = RequestFactory().post('/', {'username': 'james'}) + john = RequestFactory().post('/', {'username': 'john'}) + + class UsernameView(RateLimitMixin, View): + ratelimit_ip = False + ratelimit_field = 'username' + ratelimit_rate = '1/m' + + def post(self, request, *args, **kwargs): + return request.limited + get = post + + username = UsernameView.as_view() + assert not username(james), "james' first request is fine." + assert username(james), "james' second request is limited." + assert not username(john), "john's first request is fine." + + def test_field_unicode(self): + post = RequestFactory().post('/', {'username': u'fran\xe7ois'}) + + class UsernameView(RateLimitMixin, View): + ratelimit_ip = False + ratelimit_field = 'username' + ratelimit_rate = '1/m' + + def post(self, request, *args, **kwargs): + return request.limited + get = post + + view = UsernameView.as_view() + + assert not view(post), 'First request is not limited.' + assert view(post), 'Second request is limited.' + + def test_field_empty(self): + post = RequestFactory().post('/', {}) + + class EmptyFieldView(RateLimitMixin, View): + ratelimit_ip = False + ratelimit_field = 'username' + ratelimit_rate = '1/m' + + def post(self, request, *args, **kwargs): + return request.limited + get = post + + view = EmptyFieldView.as_view() + + assert not view(post), 'First request is not limited.' + assert view(post), 'Second request is limited.' + + def test_rate(self): + req = RequestFactory().post('/') + + class TwiceView(RateLimitMixin, View): + ratelimit_ip = True + ratelimit_rate = '2/m' + + def post(self, request, *args, **kwargs): + return request.limited + get = post + + twice = TwiceView.as_view() + + assert not twice(req), 'First request is not limited.' + assert not twice(req), 'Second request is not limited.' + assert twice(req), 'Third request is limited.' + + def test_skip_if(self): + req = RequestFactory().post('/') + + class SkipIfView(RateLimitMixin, View): + ratelimit_rate = '1/m' + ratelimit_skip_if = lambda r: getattr(r, 'skip', False) + + def post(self, request, *args, **kwargs): + return request.limited + get = post + view = SkipIfView.as_view() + + assert not view(req), 'First request is not limited.' + assert view(req), 'Second request is limited.' + del req.limited + req.skip = True + assert not view(req), 'Skipped request is not limited.' + + @override_settings(RATELIMIT_USE_CACHE='fake-cache') + def test_bad_cache(self): + """The RATELIMIT_USE_CACHE setting works if the cache exists.""" + + class BadCacheView(RateLimitMixin, View): + + def post(self, request, *args, **kwargs): + return request + get = post + view = BadCacheView.as_view() + + req = RequestFactory().post('/') + + with self.assertRaises(InvalidCacheBackendError): + view(req) + + def test_keys(self): + """Allow custom functions to set cache keys.""" + class User(object): + def __init__(self, authenticated=False): + self.pk = 1 + self.authenticated = authenticated + + def is_authenticated(self): + return self.authenticated + + def user_or_ip(req): + if req.user.is_authenticated(): + return 'uip:%d' % req.user.pk + return 'uip:%s' % req.META['REMOTE_ADDR'] + + class KeysView(RateLimitMixin, View): + ratelimit_ip = False + ratelimit_block = False + ratelimit_rate = '1/m' + ratelimit_keys = user_or_ip + + def post(self, request, *args, **kwargs): + return request.limited + get = post + view = KeysView.as_view() + + req = RequestFactory().post('/') + req.user = User(authenticated=False) + + assert not view(req), 'First unauthenticated request is allowed.' + assert view(req), 'Second unauthenticated request is limited.' + + del req.limited + req.user = User(authenticated=True) + + assert not view(req), 'First authenticated request is allowed.' + assert view(req), 'Second authenticated is limited.' diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..20ea3f9 --- /dev/null +++ b/run.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +export PYTHONPATH=".:$PYTHONPATH" +export DJANGO_SETTINGS_MODULE="test_settings" + +usage() { + echo "USAGE: $0 [command]" + echo " test - run the jsonview tests" + echo " shell - open the Django shell" + exit 1 +} + +case "$1" in + "test" ) + django-admin.py test ratelimit ;; + "shell" ) + django-admin.py shell ;; + * ) + usage ;; +esac diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..878cd5c --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +from setuptools import setup, find_packages + +from ratelimit import __version__ + + +setup( + name='django-ratelimit', + version=__version__, + description='Cache-based rate-limiting for Django.', + long_description=open('README.rst').read(), + author='James Socol', + author_email='james@mozilla.com', + url='http://github.com/jsocol/django-ratelimit', + license='Apache Software License', + packages=find_packages(exclude=['test_settings']), + include_package_data=True, + package_data = {'': ['README.rst']}, + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'Environment :: Web Environment :: Mozilla', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Software Development :: Libraries :: Python Modules', + ] +) diff --git a/test_settings.py b/test_settings.py new file mode 100644 index 0000000..e472f94 --- /dev/null +++ b/test_settings.py @@ -0,0 +1,21 @@ +SECRET_KEY = 'ratelimit' + +INSTALLED_APPS = ( + 'ratelimit', +) + +RATELIMIT_USE_CACHE = 'default' + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'ratelimit-tests', + }, +} + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'test.db', + }, +} diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..501d997 --- /dev/null +++ b/tox.ini @@ -0,0 +1,40 @@ +[testenv] +commands = ./run.sh test + +# python 3.3 + +[testenv:py33-1.6] +basepython = python3.3 +deps = Django>=1.6,<1.6.99 + +[testenv:py33-1.5] +basepython = python3.3 +deps = Django>=1.5,<1.5.99 + +# python 2.7 + +[testenv:py27-1.6] +basepython = python2.7 +deps = Django>=1.6,<1.6.99 + +[testenv:py27-1.5] +basepython = python2.7 +deps = Django>=1.5,<1.5.99 + +[testenv:py27-1.4] +basepython = python2.7 +deps = Django>=1.4,<1.4.99 + +# python 2.6 + +[testenv:py26-1.6] +basepython = python2.6 +deps = Django>=1.6,<1.6.99 + +[testenv:py26-1.5] +basepython = python2.6 +deps = Django>=1.5,<1.5.99 + +[testenv:py26-1.4] +basepython = python2.6 +deps = Django>=1.4,<1.4.99