├── tests
├── __init__.py
├── urls.py
├── run.py
├── test_connection.py
├── test_pool.py
├── settings.py
├── utils.py
├── test_response.py
├── test_views.py
├── test_utils.py
└── test_request.py
├── setup.cfg
├── drfreverseproxy
├── __init__.py
├── exceptions.py
├── connection.py
├── pool.py
├── response.py
├── utilites.py
└── views.py
├── MANIFEST.in
├── mkdocs.yml
├── .gitignore
├── requirements.txt
├── docs
├── css
│ └── extra.css
└── index.md
├── tox.ini
├── .travis.yml
├── README.md
├── runtests.py
├── setup.py
└── LICENSE
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [wheel]
2 | universal = 1
3 |
--------------------------------------------------------------------------------
/drfreverseproxy/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = '1.0.1'
2 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst LICENSE
2 | recursive-exclude * __pycache__
3 | recursive-exclude * *.py[co]
4 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import url
2 | from django.contrib.auth.views import login
3 |
4 | urlpatterns = [
5 | url(r'^accounts/login/$', login, name='login'),
6 | ]
7 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: drf-reverse-proxy
2 | site_description: Reverse proxy view for Django Rest Framework
3 | repo_url: https://github.com/danpoland/drf-reverse-proxy
4 | site_dir: html
5 |
--------------------------------------------------------------------------------
/drfreverseproxy/exceptions.py:
--------------------------------------------------------------------------------
1 | class ReverseProxyException(Exception):
2 | """
3 | Base exception
4 | """
5 |
6 |
7 | class InvalidUpstream(ReverseProxyException):
8 | """
9 | Invalid upstream
10 | """
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.db
3 | *~
4 | .*
5 |
6 | html/
7 | htmlcov/
8 | coverage/
9 | build/
10 | dist/
11 | *.egg-info/
12 | MANIFEST
13 |
14 | bin/
15 | include/
16 | lib/
17 | local/
18 |
19 | .idea/
20 | venv/
21 |
22 | !.gitignore
23 | !.travis.yml
24 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # Minimum Django and REST framework version
2 | Django>=1.8
3 | djangorestframework>=3.3.0
4 |
5 | # Test requirements
6 | pytest-django==3.0.0
7 | pytest==3.0.3
8 | pytest-cov==2.4
9 | flake8==2.2.2
10 | mock==2.0.0
11 |
12 | # wheel for PyPI installs
13 | wheel==0.24.0
14 |
15 | # MkDocs for documentation previews/deploys
16 | mkdocs==0.11.1
17 |
18 | # Application dependencies
19 | urllib3==1.24.2
20 | six~=1.15.0
21 |
--------------------------------------------------------------------------------
/docs/css/extra.css:
--------------------------------------------------------------------------------
1 | body.homepage div.col-md-9 h1:first-of-type {
2 | text-align: center;
3 | font-size: 60px;
4 | font-weight: 300;
5 | margin-top: 0;
6 | }
7 |
8 | body.homepage div.col-md-9 p:first-of-type {
9 | text-align: center;
10 | }
11 |
12 | body.homepage .badges {
13 | text-align: right;
14 | }
15 |
16 | body.homepage .badges a {
17 | display: inline-block;
18 | }
19 |
20 | body.homepage .badges a img {
21 | padding: 0;
22 | margin: 0;
23 | }
24 |
--------------------------------------------------------------------------------
/tests/run.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import sys
5 |
6 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings'
7 |
8 | import django
9 |
10 | from django.test.utils import get_runner
11 | from django.conf import settings
12 |
13 |
14 | def runtests():
15 | if django.VERSION >= (1, 7, 0):
16 | django.setup()
17 |
18 | test_runner = get_runner(settings)
19 | failures = test_runner(interactive=False, failfast=False).run_tests([])
20 | sys.exit(failures)
21 |
22 |
23 | if __name__ == '__main__':
24 | runtests()
25 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = {py27,py34,py35}-django{1.8,1.9,1.10,1.11}-drf{3.3,3.4,3.5,3.6}
3 |
4 | [testenv]
5 | commands =
6 | flake8 drfreverseproxy -v
7 | coverage run --branch --source=drfreverseproxy setup.py test
8 | coverage report --show-missing
9 | setenv =
10 | PYTHONDONTWRITEBYTECODE=1
11 | deps =
12 | coverage
13 | django1.8: Django==1.8.15
14 | django1.9: Django==1.9.10
15 | django1.10: Django==1.10.2
16 | django1.11: Django==1.11.3
17 | drf3.3: djangorestframework==3.3.3
18 | drf3.4: djangorestframework==3.4.7
19 | drf3.5: djangorestframework==3.5.1
20 | drf3.6: djangorestframework==3.6.3
21 |
--------------------------------------------------------------------------------
/drfreverseproxy/connection.py:
--------------------------------------------------------------------------------
1 | from urllib3.connection import HTTPConnection, HTTPSConnection
2 | from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
3 |
4 |
5 | def _output(self, s):
6 | """
7 | Host header should always be first
8 | """
9 |
10 | if s.lower().startswith(b'host: '):
11 | self._buffer.insert(1, s)
12 | else:
13 | self._buffer.append(s)
14 |
15 | HTTPConnectionPool.ConnectionCls = type(
16 | 'RevProxyHTTPConnection',
17 | (HTTPConnection,),
18 | {'_output': _output},
19 | )
20 |
21 | HTTPSConnectionPool.ConnectionCls = type(
22 | 'RevProxyHTTPSConnection',
23 | (HTTPSConnection,),
24 | {'_output': _output}
25 | )
26 |
--------------------------------------------------------------------------------
/tests/test_connection.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from drfreverseproxy import connection
4 |
5 |
6 | class TestOutput(TestCase):
7 |
8 | def setUp(self):
9 | self.connection = connection.HTTPConnectionPool.ConnectionCls('example.com')
10 |
11 | def test_byte_url(self):
12 | """Output strings are always byte strings, even using Python 3"""
13 | mock_output = b'mock output'
14 | connection._output(self.connection, mock_output)
15 | self.assertEqual(self.connection._buffer, [mock_output])
16 |
17 | def test_host_is_first(self):
18 | """Make sure the host line is second in the request"""
19 | mock_host_output = b'host: example.com'
20 | for output in [b'GET / HTTP/1.1', b'before', mock_host_output, b'after']:
21 | connection._output(self.connection, output)
22 | self.assertEqual(self.connection._buffer[1], mock_host_output)
23 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 |
3 | sudo: false
4 |
5 | python:
6 | - "2.7"
7 | - "3.5"
8 | - "3.6"
9 |
10 | env:
11 | - DJANGO_VERSION=1.9.13 DRF_VERSION=3.3.3
12 | - DJANGO_VERSION=1.9.13 DRF_VERSION=3.4.7
13 | - DJANGO_VERSION=1.9.13 DRF_VERSION=3.5.4
14 | - DJANGO_VERSION=1.10.7 DRF_VERSION=3.3.3
15 | - DJANGO_VERSION=1.10.7 DRF_VERSION=3.4.7
16 | - DJANGO_VERSION=1.10.7 DRF_VERSION=3.5.4
17 | - DJANGO_VERSION=1.11.3 DRF_VERSION=3.4.7
18 | - DJANGO_VERSION=1.11.3 DRF_VERSION=3.5.4
19 | - DJANGO_VERSION=1.11.3 DRF_VERSION=3.6.3
20 |
21 | install:
22 | - pip install --upgrade setuptools
23 | - pip install --upgrade pip
24 | - pip install coveralls flake8
25 | - pip install django==${DJANGO_VERSION} djangorestframework==${DRF_VERSION}
26 |
27 | script:
28 | - coverage run --branch --source=drfreverseproxy setup.py test
29 | - coverage report --show-missing
30 |
31 | after_script:
32 | - coveralls
33 |
--------------------------------------------------------------------------------
/drfreverseproxy/pool.py:
--------------------------------------------------------------------------------
1 | from urllib3.poolmanager import PoolManager as PoolManager_, SSL_KEYWORDS
2 |
3 | from .connection import HTTPConnectionPool, HTTPSConnectionPool
4 |
5 |
6 | pool_classes_by_scheme = {
7 | 'http': HTTPConnectionPool,
8 | 'https': HTTPSConnectionPool,
9 | }
10 |
11 |
12 | class PoolManager(PoolManager_):
13 | def _new_pool(self, scheme, host, port, request_context=None):
14 | """
15 | Create a new :class:`ConnectionPool` based on host, port and scheme.
16 | This method is used to actually create the connection pools handed out
17 | by :meth:`connection_from_url` and companion methods. It is intended
18 | to be overridden for customization.
19 | """
20 |
21 | pool_cls = pool_classes_by_scheme[scheme]
22 | kwargs = self.connection_pool_kw
23 |
24 | if scheme == 'http':
25 | kwargs = self.connection_pool_kw.copy()
26 |
27 | for kw in SSL_KEYWORDS:
28 | kwargs.pop(kw, None)
29 |
30 | return pool_cls(host, port, **kwargs)
31 |
--------------------------------------------------------------------------------
/tests/test_pool.py:
--------------------------------------------------------------------------------
1 | import mock
2 |
3 | from django.test import TestCase
4 | from urllib3.poolmanager import SSL_KEYWORDS
5 |
6 | from drfreverseproxy import connection, pool
7 |
8 |
9 | mock_http_pool = mock.Mock(wraps=connection.HTTPConnectionPool)
10 |
11 |
12 | class TestPoolManager(TestCase):
13 |
14 | def test_new_pool(self):
15 | new_pool = pool.PoolManager()._new_pool('https', 'example.com', '443')
16 | self.assertIsInstance(new_pool, connection.HTTPSConnectionPool)
17 |
18 | @mock.patch('drfreverseproxy.pool.pool_classes_by_scheme', {'http': mock_http_pool})
19 | def test_new_http_pool(self):
20 | example_ssl_key = SSL_KEYWORDS[0]
21 | mock_non_ssl_key = 'mock_keyword'
22 | mock_non_ssl_value = 'mock non-ssl value'
23 | mock_host_and_port = ('example.com', '80')
24 |
25 | pool_manager = pool.PoolManager()
26 | pool_manager.connection_pool_kw.update({
27 | example_ssl_key: 'mock ssl value',
28 | mock_non_ssl_key: mock_non_ssl_value,
29 | })
30 | new_pool = pool_manager._new_pool('http', *mock_host_and_port)
31 |
32 | mock_http_pool.assert_called_once_with(
33 | *mock_host_and_port,
34 | **{mock_non_ssl_key: mock_non_ssl_value}
35 | )
36 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 |
2 | SECRET_KEY = 'asdf'
3 |
4 |
5 | DATABASES = {
6 | 'default': {
7 | 'NAME': 'test.db',
8 | 'ENGINE': 'django.db.backends.sqlite3',
9 | }
10 | }
11 |
12 | INSTALLED_APPS = (
13 | 'django.contrib.auth',
14 | 'django.contrib.contenttypes',
15 | 'django.contrib.staticfiles',
16 | 'rest_framework',
17 | 'drfreverseproxy',
18 | )
19 |
20 | MIDDLEWARE_CLASSES = (
21 | 'django.contrib.sessions.middleware.SessionMiddleware',
22 | 'django.middleware.common.CommonMiddleware',
23 | 'django.middleware.csrf.CsrfViewMiddleware',
24 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
25 | 'django.contrib.messages.middleware.MessageMiddleware',
26 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
27 | )
28 |
29 | ROOT_URLCONF = 'tests.urls'
30 |
31 | import os
32 |
33 | BASE_DIR = os.path.dirname(os.path.abspath(__file__))
34 |
35 | TEMPLATE_DIRS = (
36 | os.path.join(BASE_DIR, 'templates'),
37 | )
38 |
39 | LOGGING = {
40 | 'version': 1,
41 |
42 | 'handlers': {
43 | 'null': {
44 | 'level': 'DEBUG',
45 | 'class': 'logging.NullHandler',
46 | },
47 | },
48 |
49 | 'loggers': {
50 | 'drfreverseproxy': {
51 | 'handlers': ['null'],
52 | 'propagate': False,
53 | },
54 | },
55 | }
56 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 |
9 |
10 | ---
11 |
12 | # drf-reverse-proxy
13 |
14 | Reverse proxy view for Django Rest Framework
15 |
16 | ---
17 |
18 | ## Overview
19 |
20 | Reverse proxy view for Django Rest Framework
21 |
22 | ## Requirements
23 |
24 | * Python (2.7, 3.3, 3.4)
25 | * Django (1.6, 1.7)
26 |
27 | ## Installation
28 |
29 | Install using `pip`...
30 |
31 | ```bash
32 | $ pip install drf-reverse-proxy
33 | ```
34 |
35 | ## Example
36 |
37 | TODO: Write example.
38 |
39 | ## Testing
40 |
41 | Install testing requirements.
42 |
43 | ```bash
44 | $ pip install -r requirements.txt
45 | ```
46 |
47 | Run with runtests.
48 |
49 | ```bash
50 | $ ./runtests.py
51 | ```
52 |
53 | You can also use the excellent [tox](http://tox.readthedocs.org/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run:
54 |
55 | ```bash
56 | $ tox
57 | ```
58 |
59 | ## Documentation
60 |
61 | To build the documentation, you'll need to install `mkdocs`.
62 |
63 | ```bash
64 | $ pip install mkdocs
65 | ```
66 |
67 | To preview the documentation:
68 |
69 | ```bash
70 | $ mkdocs serve
71 | Running at: http://127.0.0.1:8000/
72 | ```
73 |
74 | To build the documentation:
75 |
76 | ```bash
77 | $ mkdocs build
78 | ```
79 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import urllib3
4 |
5 | from io import BytesIO
6 |
7 | from mock import MagicMock, Mock
8 |
9 | from drfreverseproxy.views import ProxyView
10 |
11 | DEFAULT_BODY_CONTENT = u'áéíóú'.encode('utf-8')
12 | URLOPEN = 'urllib3.PoolManager.urlopen'
13 |
14 |
15 | class CustomProxyView(ProxyView):
16 | upstream = "http://www.example.com"
17 | diazo_rules = None
18 |
19 |
20 | def get_urlopen_mock(body=DEFAULT_BODY_CONTENT, headers=None, status=200):
21 | mockHttpResponse = Mock(name='httplib.HTTPResponse')
22 |
23 | headers = urllib3.response.HTTPHeaderDict(headers)
24 |
25 | if not hasattr(body, 'read'):
26 | body = BytesIO(body)
27 |
28 | else:
29 | body.seek(0)
30 |
31 | urllib3_response = urllib3.HTTPResponse(body,
32 | headers,
33 | status,
34 | preload_content=False,
35 | original_response=mockHttpResponse)
36 |
37 | return MagicMock(return_value=urllib3_response)
38 |
39 |
40 | class MockFile():
41 |
42 | def __init__(self, content, read_size=4):
43 | self.content = content
44 | self.mock_file = BytesIO(content)
45 | self.mock_read_size = read_size
46 |
47 | def closed(self):
48 | return self.mock_file.closed
49 |
50 | def close(self):
51 | self.mock_file.close()
52 |
53 | def read(self, size=-1):
54 | return self.mock_file.read(self.mock_read_size)
55 |
56 | def seek(self, size):
57 | return self.mock_file.seek(size)
58 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | drf-reverse-proxy
2 | ======================================
3 |
4 | [![pypi-version]][pypi]
5 | [![build-status-image]][travis]
6 |
7 | Overview
8 | --------
9 |
10 | This is a Django REST Framework port of the excellent django-revproxy (https://github.com/TracyWebTech/django-revproxy) library.
11 |
12 | This port allows you to reverse proxy HTTP requests while still utilizing DRF features such as authentication, permissions and throttling.
13 |
14 | This library works exactly like the django-revproxy library, the documentation for django-revproxy can be found at: http://django-revproxy.readthedocs.org/
15 |
16 |
17 | Features
18 | ---------
19 |
20 | * Proxies all HTTP methods: HEAD, GET, POST, PUT, DELETE, OPTIONS, TRACE, CONNECT and PATCH
21 | * Copy all http headers sent from the client to the proxied server
22 | * Copy all http headers sent from the proxied server to the client (except `hop-by-hop `_)
23 | * Basic URL rewrite
24 | * Handles redirects
25 | * Few external dependencies
26 | * Maintains the usability of DRF features like authentication, permissions and throttling.
27 |
28 |
29 | Requirements
30 | ------------
31 |
32 | - Python (2.7, 3.3, 3.4, 3.5)
33 | - Django (1.8, 1.9, 1.10)
34 | - Django REST Framework (3.3, 3.4, 3.5)
35 |
36 | Installation
37 | ------------
38 |
39 | Install using ``pip``\ …
40 |
41 | ```bash
42 | $ pip install drf-reverse-proxy
43 | ```
44 |
45 | Example
46 | -------
47 |
48 | Create a custom reverse proxy view:
49 |
50 | ```python
51 | from drfreverseproxy import ProxyView
52 |
53 | class TestProxyView(ProxyView):
54 | upstream = 'http://example.com'
55 | ```
56 |
57 | Or just use the default:
58 |
59 | ```python
60 | from drfreverseproxy import ProxyView
61 |
62 | urlpatterns = [
63 | url(r'^(?P.*)$', ProxyView.as_view(upstream='http://example.com/')),
64 | ]
65 | ```
66 |
67 | [build-status-image]: https://travis-ci.org/danpoland/drf-reverse-proxy.svg?branch=master
68 | [travis]: https://travis-ci.org/danpoland/drf-reverse-proxy
69 | [pypi-version]: https://img.shields.io/pypi/v/drf-reverse-proxy.svg
70 | [pypi]: https://pypi.python.org/pypi/drf-reverse-proxy
71 |
--------------------------------------------------------------------------------
/drfreverseproxy/response.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.http import HttpResponse, StreamingHttpResponse
4 |
5 | from .utilites import cookie_from_string, should_stream, set_response_headers
6 |
7 |
8 | #: Default number of bytes that are going to be read in a file lecture
9 | DEFAULT_AMT = 2 ** 16
10 |
11 | logger = logging.getLogger(__name__ + '.response')
12 |
13 |
14 | def get_django_response(proxy_response):
15 | """
16 | This method is used to create an appropriate response based on the Content-Length of the proxy_response.
17 | If the content is bigger than MIN_STREAMING_LENGTH, which is found on utils.py,
18 | than django.http.StreamingHttpResponse will be created, else a django.http.HTTPResponse will be created instead.
19 |
20 | :param proxy_response: An Instance of urllib3.response.HTTPResponse that will create an appropriate response
21 | :returns: Returns an appropriate response based on the proxy_response content-length
22 | """
23 |
24 | status = proxy_response.status
25 | headers = proxy_response.headers
26 | content_type = headers.get('Content-Type')
27 |
28 | logger.debug('Proxy response headers: %s', headers)
29 | logger.debug('Content-Type: %s', content_type)
30 |
31 | if should_stream(proxy_response):
32 | logger.info('Content-Length is bigger than %s', DEFAULT_AMT)
33 | response = StreamingHttpResponse(proxy_response.stream(DEFAULT_AMT), status=status, content_type=content_type)
34 | else:
35 | content = proxy_response.data or b''
36 | response = HttpResponse(content, status=status, content_type=content_type)
37 |
38 | logger.info('Normalizing response headers')
39 | set_response_headers(response, headers)
40 |
41 | if hasattr(response, 'headers'):
42 | logger.debug('Response headers: %s', response.headers)
43 | else:
44 | logger.debug('Response headers: %s', getattr(response, '_headers'))
45 |
46 | cookies = proxy_response.headers.getlist('set-cookie')
47 | logger.info('Checking for invalid cookies')
48 |
49 | for cookie_string in cookies:
50 | cookie_dict = cookie_from_string(cookie_string)
51 | # if cookie is invalid cookie_dict will be None
52 | if cookie_dict:
53 | response.set_cookie(**cookie_dict)
54 |
55 | logger.debug('Response cookies: %s', response.cookies)
56 |
57 | return response
58 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python
2 | from __future__ import print_function
3 |
4 | import pytest
5 | import sys
6 | import os
7 | import subprocess
8 |
9 |
10 | PYTEST_ARGS = {
11 | 'default': ['tests'],
12 | 'fast': ['tests', '-q'],
13 | }
14 |
15 | FLAKE8_ARGS = ['drfreverseproxy', 'tests', '--ignore=E501']
16 |
17 |
18 | sys.path.append(os.path.dirname(__file__))
19 |
20 |
21 | def exit_on_failure(ret, message=None):
22 | if ret:
23 | sys.exit(ret)
24 |
25 |
26 | def flake8_main(args):
27 | print('Running flake8 code linting')
28 | ret = subprocess.call(['flake8'] + args)
29 | print('flake8 failed' if ret else 'flake8 passed')
30 | return ret
31 |
32 |
33 | def split_class_and_function(string):
34 | class_string, function_string = string.split('.', 1)
35 | return "%s and %s" % (class_string, function_string)
36 |
37 |
38 | def is_function(string):
39 | # `True` if it looks like a test function is included in the string.
40 | return string.startswith('test_') or '.test_' in string
41 |
42 |
43 | def is_class(string):
44 | # `True` if first character is uppercase - assume it's a class name.
45 | return string[0] == string[0].upper()
46 |
47 |
48 | if __name__ == "__main__":
49 | try:
50 | sys.argv.remove('--nolint')
51 | except ValueError:
52 | run_flake8 = True
53 | else:
54 | run_flake8 = False
55 |
56 | try:
57 | sys.argv.remove('--lintonly')
58 | except ValueError:
59 | run_tests = True
60 | else:
61 | run_tests = False
62 |
63 | try:
64 | sys.argv.remove('--fast')
65 | except ValueError:
66 | style = 'default'
67 | else:
68 | style = 'fast'
69 | run_flake8 = False
70 |
71 | if len(sys.argv) > 1:
72 | pytest_args = sys.argv[1:]
73 | first_arg = pytest_args[0]
74 | if first_arg.startswith('-'):
75 | # `runtests.py [flags]`
76 | pytest_args = ['tests'] + pytest_args
77 | elif is_class(first_arg) and is_function(first_arg):
78 | # `runtests.py TestCase.test_function [flags]`
79 | expression = split_class_and_function(first_arg)
80 | pytest_args = ['tests', '-k', expression] + pytest_args[1:]
81 | elif is_class(first_arg) or is_function(first_arg):
82 | # `runtests.py TestCase [flags]`
83 | # `runtests.py test_function [flags]`
84 | pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:]
85 | else:
86 | pytest_args = PYTEST_ARGS[style]
87 |
88 | if run_tests:
89 | exit_on_failure(pytest.main(pytest_args))
90 | if run_flake8:
91 | exit_on_failure(flake8_main(FLAKE8_ARGS))
92 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import re
4 | import os
5 | import sys
6 | from setuptools import setup
7 |
8 |
9 | name = 'drf-reverse-proxy'
10 | package = 'drfreverseproxy'
11 | description = 'Reverse proxy view for Django Rest Framework'
12 | url = 'https://github.com/danpoland/drf-reverse-proxy'
13 | author = 'Daniel Poland'
14 | author_email = 'danpoland84@gmail.com'
15 | license = 'MPL-2.0'
16 |
17 |
18 | def get_version(package):
19 | """
20 | Return package version as listed in `__version__` in `init.py`.
21 | """
22 | init_py = open(os.path.join(package, '__init__.py')).read()
23 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]",
24 | init_py, re.MULTILINE).group(1)
25 |
26 |
27 | def get_packages(package):
28 | """
29 | Return root package and all sub-packages.
30 | """
31 | return [dirpath
32 | for dirpath, dirnames, filenames in os.walk(package)
33 | if os.path.exists(os.path.join(dirpath, '__init__.py'))]
34 |
35 |
36 | def get_package_data(package):
37 | """
38 | Return all files under the root package, that are not in a
39 | package themselves.
40 | """
41 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames)
42 | for dirpath, dirnames, filenames in os.walk(package)
43 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))]
44 |
45 | filepaths = []
46 | for base, filenames in walk:
47 | filepaths.extend([os.path.join(base, filename)
48 | for filename in filenames])
49 | return {package: filepaths}
50 |
51 |
52 | version = get_version(package)
53 |
54 |
55 | if sys.argv[-1] == 'publish':
56 | if os.system("pip freeze | grep wheel"):
57 | print("wheel not installed.\nUse `pip install wheel`.\nExiting.")
58 | sys.exit()
59 | os.system("python setup.py sdist upload")
60 | os.system("python setup.py bdist_wheel upload")
61 | print("You probably want to also tag the version now:")
62 | print(" git tag -a {0} -m 'version {0}'".format(version))
63 | print(" git push --tags")
64 | sys.exit()
65 |
66 |
67 | setup(
68 | name=name,
69 | version=version,
70 | url=url,
71 | license=license,
72 | description=description,
73 | author=author,
74 | author_email=author_email,
75 | packages=get_packages(package),
76 | package_data=get_package_data(package),
77 | install_requires=[
78 | 'django>=1.8',
79 | 'djangorestframework>=3.3.0',
80 | 'urllib3>=1.12',
81 | ],
82 | tests_require=['mock'],
83 | test_suite="tests.run.runtests",
84 | classifiers=[
85 | 'Development Status :: 5 - Production/Stable',
86 | 'Environment :: Web Environment',
87 | 'Framework :: Django',
88 | 'Intended Audience :: Developers',
89 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)',
90 | 'Operating System :: OS Independent',
91 | 'Natural Language :: English',
92 | 'Programming Language :: Python :: 2.7',
93 | 'Programming Language :: Python :: 3.5',
94 | 'Programming Language :: Python :: 3.6',
95 | 'Topic :: Internet :: WWW/HTTP',
96 | ]
97 | )
98 |
--------------------------------------------------------------------------------
/tests/test_response.py:
--------------------------------------------------------------------------------
1 | #! *-* coding: utf8 *-*
2 |
3 | import logging
4 |
5 | import urllib3
6 |
7 | from wsgiref.util import is_hop_by_hop
8 |
9 | from django.test import RequestFactory, TestCase
10 | from mock import MagicMock, patch
11 | from urllib3.exceptions import HTTPError
12 |
13 | from .utils import (get_urlopen_mock, DEFAULT_BODY_CONTENT,
14 | CustomProxyView, URLOPEN)
15 |
16 |
17 | class ResponseTest(TestCase):
18 | def setUp(self):
19 | self.factory = RequestFactory()
20 | self.log = logging.getLogger('drfreverseproxy')
21 | self.log.disabled = True
22 |
23 | def tearDown(self):
24 | CustomProxyView.upstream = "http://www.example.com"
25 | CustomProxyView.diazo_rules = None
26 | self.log.disabled = False
27 |
28 | def test_broken_response(self):
29 | request = self.factory.get('/')
30 |
31 | urlopen_mock = MagicMock(side_effect=HTTPError())
32 | with patch(URLOPEN, urlopen_mock), self.assertRaises(HTTPError):
33 | CustomProxyView.as_view()(request, path='/')
34 |
35 | def test_location_replaces_request_host(self):
36 | headers = {'Location': 'http://www.example.com'}
37 | path = "/path"
38 | request = self.factory.get(path)
39 |
40 | urlopen_mock = get_urlopen_mock(headers=headers)
41 | with patch(URLOPEN, urlopen_mock):
42 | response = CustomProxyView.as_view()(request, path=path)
43 |
44 | location = "http://" + request.get_host()
45 | self.assertEqual(location, response['Location'])
46 |
47 | def test_location_replaces_secure_request_host(self):
48 | CustomProxyView.upstream = "https://www.example.com"
49 |
50 | headers = {'Location': 'https://www.example.com'}
51 | path = "/path"
52 | request = self.factory.get(
53 | path,
54 | # using kwargs instead of the secure parameter because it
55 | # works only after Django 1.7
56 | **{
57 | 'wsgi.url_scheme': 'https' # tell factory to use
58 | } # https over http
59 | )
60 |
61 | urlopen_mock = get_urlopen_mock(headers=headers)
62 | with patch(URLOPEN, urlopen_mock):
63 | response = CustomProxyView.as_view()(request, path=path)
64 |
65 | location = "https://" + request.get_host()
66 | self.assertEqual(location, response['Location'])
67 |
68 | def test_response_headers_are_not_in_hop_by_hop_headers(self):
69 | path = "/"
70 | request = self.factory.get(path)
71 | headers = {
72 | 'connection': '0',
73 | 'proxy-authorization': 'allow',
74 | 'content-type': 'text/html',
75 | }
76 |
77 | urlopen_mock = get_urlopen_mock(headers=headers)
78 | with patch(URLOPEN, urlopen_mock):
79 | response = CustomProxyView.as_view()(request, path=path)
80 |
81 | if hasattr(response, 'headers'):
82 | response_headers = response.headers
83 | else:
84 | response_headers = response._headers
85 |
86 | for header in response_headers:
87 | self.assertFalse(is_hop_by_hop(header))
88 |
89 | def test_response_code_remains_the_same(self):
90 | path = "/"
91 | request = self.factory.get(path)
92 | status = 300
93 |
94 | urlopen_mock = get_urlopen_mock(status=status)
95 | with patch(URLOPEN, urlopen_mock):
96 | response = CustomProxyView.as_view()(request, path=path)
97 |
98 | self.assertEqual(response.status_code, status)
99 |
100 | def test_response_content_remains_the_same(self):
101 | path = "/"
102 | request = self.factory.get(path)
103 | status = 300
104 |
105 | headers = {'Content-Type': 'text/html'}
106 | urlopen_mock = get_urlopen_mock(DEFAULT_BODY_CONTENT, headers, status)
107 | with patch(URLOPEN, urlopen_mock):
108 | response = CustomProxyView.as_view()(request, path=path)
109 |
110 | # had to prefix it with 'b' because Python 3 treats str and byte
111 | # differently
112 | self.assertEqual(DEFAULT_BODY_CONTENT, response.content)
113 |
114 | def test_cookie_is_not_in_response_headers(self):
115 | path = "/"
116 | request = self.factory.get(path)
117 | headers = {
118 | 'connection': '0',
119 | 'proxy-authorization': 'allow',
120 | 'content-type': 'text/html',
121 | 'set-cookie': '_cookie=9as8sd32fg48gh2j4k7o3;path=/'
122 | }
123 |
124 | urlopen_mock = get_urlopen_mock(headers=headers)
125 | with patch(URLOPEN, urlopen_mock):
126 | response = CustomProxyView.as_view()(request, path=path)
127 |
128 | if hasattr(response, 'headers'):
129 | response_headers = response.headers
130 | else:
131 | response_headers = response._headers
132 | self.assertNotIn('set-cookie', response_headers)
133 |
134 | def test_set_cookie_is_used_by_httpproxy_response(self):
135 | path = "/"
136 | request = self.factory.get(path)
137 | headers = urllib3.response.HTTPHeaderDict({
138 | 'connection': '0',
139 | 'proxy-authorization': 'allow',
140 | 'content-type': 'text/html'
141 | })
142 | headers.add('set-cookie', '_cookie1=l4hs3kdf2jsh2324')
143 | headers.add('set-cookie', '_cookie2=l2lk5sj3df22sk3j4')
144 |
145 | urlopen_mock = get_urlopen_mock(headers=headers)
146 | with patch(URLOPEN, urlopen_mock):
147 | response = CustomProxyView.as_view()(request, path=path)
148 |
149 | self.assertIn("_cookie1", response.cookies.keys())
150 | self.assertIn("_cookie2", response.cookies.keys())
151 |
152 | def test_invalid_cookie(self):
153 | path = "/"
154 | request = self.factory.get(path)
155 | headers = {
156 | 'connection': '0',
157 | 'proxy-authorization': 'allow',
158 | 'content-type': 'text/html',
159 | 'set-cookie': 'invalid-cookie',
160 | }
161 |
162 | urlopen_mock = get_urlopen_mock(headers=headers)
163 | with patch(URLOPEN, urlopen_mock):
164 | response = CustomProxyView.as_view()(request, path=path)
165 |
166 | response_headers = response._headers
167 | self.assertFalse(response.cookies)
168 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 |
2 | from mock import patch
3 |
4 | import os
5 |
6 | from django.test import TestCase, RequestFactory
7 | from six.moves.urllib.parse import ParseResult
8 |
9 | from drfreverseproxy.exceptions import InvalidUpstream
10 | from drfreverseproxy.views import ProxyView
11 | from .utils import get_urlopen_mock
12 |
13 |
14 | class ViewTest(TestCase):
15 |
16 | def setUp(self):
17 | self.factory = RequestFactory()
18 | urlopen_mock = get_urlopen_mock()
19 | self.urlopen_patcher = patch('urllib3.PoolManager.urlopen',
20 | urlopen_mock)
21 | self.urlopen = self.urlopen_patcher.start()
22 |
23 | def test_connection_pool_singleton(self):
24 | view1 = ProxyView(upstream='http://example.com/')
25 | view2 = ProxyView(upstream='http://example.com/')
26 | self.assertIs(view1.http, view2.http)
27 |
28 | def test_url_injection(self):
29 | path = 'http://example.org'
30 | request = self.factory.get(path)
31 |
32 | view = ProxyView.as_view(upstream='http://example.com/')
33 | view(request, path=path)
34 |
35 | headers = {u'Cookie': u''}
36 | url = 'http://example.com/http://example.org'
37 |
38 | self.urlopen.assert_called_with('GET', url,
39 | body=b'',
40 | redirect=False,
41 | retries=None,
42 | preload_content=False,
43 | decode_content=False,
44 | headers=headers)
45 |
46 | def test_set_upstream_as_argument(self):
47 | url = 'http://example.com/'
48 | view = ProxyView.as_view(upstream=url)
49 |
50 | request = self.factory.get('')
51 | response = view(request, path='')
52 |
53 | headers = {u'Cookie': u''}
54 | self.urlopen.assert_called_with('GET', url,
55 | body=b'',
56 | redirect=False,
57 | retries=None,
58 | preload_content=False,
59 | decode_content=False,
60 | headers=headers)
61 |
62 | def test_upstream_not_implemented(self):
63 | with self.assertRaises(NotImplementedError):
64 | proxy_view = ProxyView()
65 |
66 | def test_upstream_without_scheme(self):
67 | class BrokenProxyView(ProxyView):
68 | upstream = 'www.example.com'
69 |
70 | with self.assertRaises(InvalidUpstream):
71 | BrokenProxyView()
72 |
73 | def test_upstream_overriden(self):
74 | class CustomProxyView(ProxyView):
75 | upstream = 'http://www.google.com/'
76 |
77 | proxy_view = CustomProxyView()
78 | self.assertEqual(proxy_view.upstream, 'http://www.google.com/')
79 |
80 | def test_upstream_without_trailing_slash(self):
81 | class CustomProxyView(ProxyView):
82 | upstream = 'http://example.com/area'
83 |
84 | request = self.factory.get('login')
85 | CustomProxyView.as_view()(request, path='login')
86 |
87 | headers = {u'Cookie': u''}
88 | self.urlopen.assert_called_with('GET', 'http://example.com/area/login',
89 | body=b'',
90 | redirect=False,
91 | retries=None,
92 | preload_content=False,
93 | decode_content=False,
94 | headers=headers)
95 |
96 | def test_tilde_is_not_escaped(self):
97 | class CustomProxyView(ProxyView):
98 | upstream = 'http://example.com'
99 |
100 | request = self.factory.get('~')
101 | CustomProxyView.as_view()(request, path='~')
102 |
103 | url = 'http://example.com/~'
104 | headers = {u'Cookie': u''}
105 | self.urlopen.assert_called_with('GET', url,
106 | body=b'',
107 | redirect=False,
108 | retries=None,
109 | preload_content=False,
110 | decode_content=False,
111 | headers=headers)
112 |
113 | def test_space_is_escaped(self):
114 | class CustomProxyView(ProxyView):
115 | upstream = 'http://example.com'
116 |
117 | path = ' test test'
118 | request = self.factory.get(path)
119 | CustomProxyView.as_view()(request, path=path)
120 |
121 | url = 'http://example.com/+test+test'
122 | headers = {u'Cookie': u''}
123 | self.urlopen.assert_called_with('GET', url,
124 | body=b'',
125 | redirect=False,
126 | retries=None,
127 | preload_content=False,
128 | decode_content=False,
129 | headers=headers)
130 |
131 | def test_extending_headers(self):
132 | class CustomProxyView(ProxyView):
133 | upstream = 'http://example.com'
134 |
135 | def get_proxy_request_headers(self, request):
136 | headers = super(CustomProxyView, self). \
137 | get_proxy_request_headers(request)
138 | headers['DNT'] = 1
139 | return headers
140 |
141 | path = ''
142 | request = self.factory.get(path)
143 | CustomProxyView.as_view()(request, path=path)
144 |
145 | url = 'http://example.com'
146 | headers = {u'Cookie': u''}
147 | custom_headers = {'DNT': 1}
148 | custom_headers.update(headers)
149 | self.urlopen.assert_called_with('GET', url,
150 | body=b'',
151 | redirect=False,
152 | retries=None,
153 | preload_content=False,
154 | decode_content=False,
155 | headers=custom_headers)
156 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 |
2 | from django.test import TestCase
3 |
4 | from mock import PropertyMock
5 |
6 | from drfreverseproxy import utilites as utils
7 |
8 | from .utils import get_urlopen_mock, CustomProxyView
9 |
10 | from django.test import RequestFactory
11 |
12 |
13 | class UtilsTest(TestCase):
14 | def test_get_charset(self):
15 | content_type = 'text/html; charset=utf-8'
16 | charset = utils.get_charset(content_type)
17 | self.assertEqual(charset, 'utf-8')
18 |
19 | def test_get_default_charset(self):
20 | content_type = ''
21 | charset = utils.get_charset(content_type)
22 | self.assertEqual('latin-1', charset)
23 |
24 | def test_required_header(self):
25 | self.assertTrue(utils.required_header('HTTP_USER_AGENT'))
26 | self.assertTrue(utils.required_header('HTTP_ANY_THING_AFTER_HTTP'))
27 |
28 | def test_ignore_host_header(self):
29 | self.assertFalse(utils.required_header('HTTP_HOST'))
30 | self.assertFalse(utils.required_header('HTTP_REMOTE_USER'))
31 | self.assertFalse(utils.required_header('HTTP_ACCEPT_ENCODING'))
32 | self.assertFalse(utils.required_header('WRONG_HEADER'))
33 |
34 | def test_ignore_accept_encoding_header(self):
35 | self.assertFalse(utils.required_header('HTTP_ACCEPT_ENCODING'))
36 |
37 | def test_is_html_content_type(self):
38 | self.assertEqual(True, utils.is_html_content_type("text/html"))
39 | self.assertEqual(True,
40 | utils.is_html_content_type('application/xhtml+xml'))
41 |
42 | def test_is_not_html_content_type(self):
43 | self.assertEqual(False, utils.is_html_content_type("html/text"))
44 | self.assertEqual(False,
45 | utils.is_html_content_type('application/pdf'))
46 |
47 | def test_not_should_stream(self):
48 | SMALLER_THAN_MIN_STREAMING_LENGTH = '5'
49 | headers = {'Content-Type': 'text/html',
50 | 'Content-Length': SMALLER_THAN_MIN_STREAMING_LENGTH}
51 |
52 | urlopen_mock = get_urlopen_mock(headers=headers)
53 |
54 | type(urlopen_mock).headers = PropertyMock(return_value=headers)
55 | self.assertEqual(False, utils.should_stream(urlopen_mock))
56 |
57 | headers['Content-Type'] = 'application/pdf'
58 | type(urlopen_mock).headers = PropertyMock(return_value=headers)
59 | self.assertEqual(False, utils.should_stream(urlopen_mock))
60 |
61 | def test_should_be_stream(self):
62 | BIGGER_THAN_MIN_STREAMING_LENGTH = '5120'
63 | headers = {'Content-Type': 'application/pdf',
64 | 'Content-Length': 'asad'}
65 |
66 | urlopen_mock = get_urlopen_mock(headers=headers)
67 |
68 | type(urlopen_mock).headers = PropertyMock(return_value=headers)
69 | self.assertEqual(True, utils.should_stream(urlopen_mock))
70 |
71 | headers['Content-Length'] = BIGGER_THAN_MIN_STREAMING_LENGTH
72 | type(urlopen_mock).headers = PropertyMock(return_value=headers)
73 | self.assertEqual(True, utils.should_stream(urlopen_mock))
74 |
75 | def test_get_dict_in_cookie_from_string(self):
76 | cookie = "_cookie_session = 1266bb13c139cfba3ed1c9c68110bae9;" \
77 | "expires=Thu, 29 Jan 2015 13:51:41 -0000; httponly;" \
78 | "Path=/gitlab"
79 |
80 | my_dict = utils.cookie_from_string(cookie)
81 | self.assertIs(type(my_dict), dict)
82 |
83 | def test_valid_attr_in_cookie_from_string(self):
84 | cookie = "_cookie_session=1266bb13c139cfba3ed1c9c68110bae9;" \
85 | "expires=Thu, 29 Jan 2015 13:51:41 -0000; httponly;" \
86 | "secure;Path=/gitlab"
87 |
88 | self.assertIn('path', utils.cookie_from_string(cookie))
89 | self.assertIn('/', utils.cookie_from_string(cookie)['path'])
90 |
91 | self.assertIn('expires', utils.cookie_from_string(cookie))
92 | self.assertIn('Thu, 29 Jan 2015 13:51:41 -0000',
93 | utils.cookie_from_string(cookie)['expires'])
94 |
95 | self.assertIn('httponly', utils.cookie_from_string(cookie))
96 | self.assertTrue(utils.cookie_from_string(cookie)['httponly'])
97 |
98 | self.assertIn('secure', utils.cookie_from_string(cookie))
99 | self.assertTrue(utils.cookie_from_string(cookie)['secure'])
100 |
101 | self.assertIn('value', utils.cookie_from_string(cookie))
102 | self.assertIn('1266bb13c139cfba3ed1c9c68110bae9',
103 | utils.cookie_from_string(cookie)['value'])
104 |
105 | self.assertIn('key', utils.cookie_from_string(cookie))
106 | self.assertIn('_cookie_session',
107 | utils.cookie_from_string(cookie)['key'])
108 |
109 | def test_None_value_cookie_from_string(self):
110 | cookie = "_cookie_session="
111 | self.assertIn('_cookie_session',
112 | utils.cookie_from_string(cookie)['key'])
113 | self.assertIn('',
114 | utils.cookie_from_string(cookie)['value'])
115 |
116 | def test_invalid_cookie_from_string(self):
117 | cookie = "_cookie_session1234c12d4p312341243"
118 | self.assertIsNone(utils.cookie_from_string(cookie))
119 |
120 | cookie = "_cookie_session==1234c12d4p312341243"
121 | self.assertIsNone(utils.cookie_from_string(cookie))
122 |
123 | cookie = "_cookie_session:123s234c1234d12"
124 | self.assertIsNone(utils.cookie_from_string(cookie))
125 |
126 | def test_invalid_attr_cookie_from_string(self):
127 | cookie = "_cookie=2j3d4k35f466l7fj9;path=/;None;"
128 |
129 | self.assertNotIn('None', utils.cookie_from_string(cookie))
130 |
131 | self.assertIn('value', utils.cookie_from_string(cookie))
132 | self.assertIn('2j3d4k35f466l7fj9',
133 | utils.cookie_from_string(cookie)['value'])
134 |
135 | self.assertIn('key', utils.cookie_from_string(cookie))
136 | self.assertIn('_cookie',
137 | utils.cookie_from_string(cookie)['key'])
138 |
139 | self.assertIn('path', utils.cookie_from_string(cookie))
140 | self.assertIn('/', utils.cookie_from_string(cookie)['path'])
141 |
142 | def test_ignore_comment_cookie_from_string(self):
143 | cookie = "_cookie=k2j3l;path=/;comment=this is a new comment;secure"
144 | self.assertNotIn('comment', utils.cookie_from_string(cookie))
145 |
146 | def test_value_exeption_cookie_from_string(self):
147 | cookie = "_cookie=k2j3l;path=/,comment=teste;httponly"
148 | self.assertIsNotNone(utils.cookie_from_string(cookie))
149 |
--------------------------------------------------------------------------------
/drfreverseproxy/utilites.py:
--------------------------------------------------------------------------------
1 |
2 | import re
3 |
4 | import logging
5 |
6 | from wsgiref.util import is_hop_by_hop
7 |
8 |
9 | # List containing string constants that are used to represent headers
10 | # that can be ignored in the required_header function
11 | IGNORE_HEADERS = (
12 | 'HTTP_ACCEPT_ENCODING', # We want content to be uncompressed so we remove the Accept-Encoding from original request
13 | 'HTTP_HOST',
14 | 'HTTP_REMOTE_USER',
15 | )
16 |
17 | # Default from HTTP RFC 2616
18 | # See: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7.1
19 | #: Variable that represent the default charset used
20 | DEFAULT_CHARSET = 'latin-1'
21 |
22 | #: List containing string constants that represents possible html content type
23 | HTML_CONTENT_TYPES = (
24 | 'text/html',
25 | 'application/xhtml+xml'
26 | )
27 |
28 | #: Variable used to represent a minimal content size required for response
29 | #: to be turned into stream
30 | MIN_STREAMING_LENGTH = 4 * 1024 # 4KB
31 |
32 | #: Regex used to find charset in a html content type
33 | _get_charset_re = re.compile(r';\s*charset=(?P[^\s;]+)', re.I)
34 |
35 |
36 | def is_html_content_type(content_type):
37 | """
38 | Function used to verify if the parameter is a proper html content type
39 |
40 | :param content_type: String variable that represent a content-type
41 | :returns: A boolean value stating if the content_type is a valid html content type
42 | """
43 |
44 | for html_content_type in HTML_CONTENT_TYPES:
45 | if content_type.startswith(html_content_type):
46 | return True
47 |
48 | return False
49 |
50 |
51 | def should_stream(proxy_response):
52 | """
53 | Function to verify if the proxy_response must be converted into
54 | a stream. This will be done by checking the proxy_response content-length
55 | and verify if its length is bigger than one stipulated by MIN_STREAMING_LENGTH.
56 |
57 | :param proxy_response: An Instance of urllib3.response.HTTPResponse
58 | :returns: A boolean stating if the proxy_response should be treated as a stream
59 | """
60 | content_type = proxy_response.headers.get('Content-Type')
61 |
62 | if is_html_content_type(content_type):
63 | return False
64 |
65 | try:
66 | content_length = int(proxy_response.headers.get('Content-Length', 0))
67 | except ValueError:
68 | content_length = 0
69 |
70 | if not content_length or content_length > MIN_STREAMING_LENGTH:
71 | return True
72 |
73 | return False
74 |
75 |
76 | def get_charset(content_type):
77 | """
78 | Function used to retrieve the charset from a content-type. If there is no charset in the content type
79 | then the charset defined on DEFAULT_CHARSET will be returned.
80 |
81 | :param content_type: A string containing a Content-Type header
82 | :returns: A string containing the charset
83 | """
84 |
85 | if not content_type:
86 | return DEFAULT_CHARSET
87 |
88 | matched = _get_charset_re.search(content_type)
89 |
90 | if matched:
91 | # Extract the charset and strip its double quotes
92 | return matched.group('charset').replace('"', '')
93 |
94 | return DEFAULT_CHARSET
95 |
96 |
97 | def required_header(header):
98 | """
99 | Function that verifies if the header parameter is a essential header.
100 |
101 | :param header: A string represented a header
102 | :returns: A boolean value that represent if the header is required
103 | """
104 |
105 | if header in IGNORE_HEADERS:
106 | return False
107 |
108 | if header.startswith('HTTP_') or header == 'CONTENT_TYPE':
109 | return True
110 |
111 | return False
112 |
113 |
114 | def set_response_headers(response, response_headers):
115 | for header, value in response_headers.items():
116 | if is_hop_by_hop(header) or header.lower() == 'set-cookie':
117 | continue
118 |
119 | response[header.title()] = value
120 | if hasattr(response, 'headers'):
121 | logger.debug('Response headers: %s', response.headers)
122 | else:
123 | logger.debug('Response headers: %s', getattr(response, '_headers'))
124 |
125 |
126 | def normalize_request_headers(request):
127 | """
128 | Function used to transform headers, replacing 'HTTP\_' to '' and replace '_' to '-'.
129 |
130 | :param request: A HttpRequest that will be transformed
131 | :returns: A dictionary with the normalized headers
132 | """
133 |
134 | norm_headers = {}
135 |
136 | for header, value in request.META.items():
137 | if required_header(header):
138 | norm_header = header.replace('HTTP_', '').title().replace('_', '-')
139 | norm_headers[norm_header] = value
140 |
141 | return norm_headers
142 |
143 |
144 | def encode_items(items):
145 | """
146 | Function that encode all elements in the list of items passed as a parameter.
147 |
148 | :param items: A list of tuple
149 | :returns: A list of tuple with all items encoded in 'utf-8'
150 | """
151 |
152 | encoded = []
153 |
154 | for key, values in items:
155 | for value in values:
156 | encoded.append((key.encode('utf-8'), value.encode('utf-8')))
157 |
158 | return encoded
159 |
160 |
161 | logger = logging.getLogger(__name__ + '.cookies')
162 |
163 |
164 | def cookie_from_string(cookie_string):
165 | """
166 | Parser for HTTP header set-cookie.
167 | The return from this function will be used as parameters for django's response.set_cookie method.
168 | Because set_cookie doesn't have parameter comment, this cookie attribute will be ignored.
169 |
170 | :param cookie_string: A string representing a valid cookie
171 | :returns: A dictionary containing the cookie_string attributes
172 | """
173 |
174 | valid_attrs = ('path', 'domain', 'comment', 'expires', 'max_age', 'httponly', 'secure')
175 | cookie_dict = {}
176 | cookie_parts = cookie_string.split(';')
177 |
178 | try:
179 | cookie_dict['key'], cookie_dict['value'] = cookie_parts[0].split('=')
180 | except ValueError:
181 | logger.warning('Invalid cookie: `%s`', cookie_string)
182 | return None
183 |
184 | for part in cookie_parts[1:]:
185 | if '=' in part:
186 | try:
187 | attr, value = part.split('=')
188 | except ValueError:
189 | logger.warning('Invalid cookie attribute: `%s`', part)
190 | continue
191 |
192 | value = value.strip()
193 | else:
194 | attr = part
195 | value = ''
196 |
197 | attr = attr.strip().lower()
198 |
199 | if not attr:
200 | continue
201 |
202 | if attr in valid_attrs:
203 | if attr in ('httponly', 'secure'):
204 | cookie_dict[attr] = True
205 | elif attr in 'comment':
206 | # ignoring comment attr as explained in the function docstring
207 | continue
208 | else:
209 | cookie_dict[attr] = value
210 | else:
211 | logger.warning('Unknown cookie attribute %s', attr)
212 |
213 | return cookie_dict
214 |
--------------------------------------------------------------------------------
/drfreverseproxy/views.py:
--------------------------------------------------------------------------------
1 | import re
2 | import logging
3 | import mimetypes
4 |
5 | import urllib3
6 |
7 | from six.moves.urllib.parse import urlparse, urlencode, quote_plus
8 | from django.shortcuts import redirect
9 |
10 | from rest_framework.views import APIView
11 |
12 | from .pool import PoolManager
13 | from .utilites import normalize_request_headers, encode_items
14 | from .response import get_django_response
15 | from .exceptions import InvalidUpstream
16 |
17 |
18 | # Chars that don't need to be quoted. We use same as nginx:
19 | # https://github.com/nginx/nginx/blob/nginx-1.9/src/core/ngx_string.c (Lines 1433-1449)
20 | QUOTE_SAFE = '<.;>\(}*+|~=-$/_:^@)[{]&\'!,"`'
21 |
22 | ERRORS_MESSAGES = {
23 | 'upstream-no-scheme': "Upstream URL scheme must be either 'http' or 'https' (%s)."
24 | }
25 |
26 | HTTP_POOLS = PoolManager()
27 |
28 |
29 | class ProxyView(APIView):
30 | """
31 | Proxy the Django request, while still tapping into DRF functionality
32 | like authentication, permissions and throttling.
33 | """
34 |
35 | _upstream = None
36 |
37 | add_remote_user = False
38 | default_content_type = 'application/json'
39 | retries = None
40 | rewrite = tuple()
41 |
42 | def __init__(self, *args, **kwargs):
43 | super(ProxyView, self).__init__(**kwargs)
44 |
45 | self._parsed_url = urlparse(self.upstream)
46 |
47 | if self._parsed_url.scheme not in ('http', 'https'):
48 | raise InvalidUpstream(ERRORS_MESSAGES['upstream-no-scheme'] % self.upstream)
49 |
50 | self._rewrite = []
51 |
52 | for from_pattern, to_pattern in self.rewrite:
53 | from_re = re.compile(from_pattern)
54 | self._rewrite.append((from_re, to_pattern))
55 |
56 | self.http = HTTP_POOLS
57 | self.log = logging.getLogger(__name__)
58 |
59 | @property
60 | def upstream(self):
61 | if not self._upstream:
62 | raise NotImplementedError('Upstream server must be set')
63 |
64 | return self._upstream
65 |
66 | @upstream.setter
67 | def upstream(self, value):
68 | self._upstream = value
69 |
70 | def _format_path_to_redirect(self, request):
71 | full_path = request.get_full_path()
72 | self.log.debug("Dispatch full path: %s", full_path)
73 |
74 | for from_re, to_pattern in self._rewrite:
75 | if from_re.match(full_path):
76 | redirect_to = from_re.sub(to_pattern, full_path)
77 | self.log.debug("Redirect to: %s", redirect_to)
78 | return redirect_to
79 |
80 | def get_proxy_request_headers(self, request):
81 | """
82 | Get normalized headers for the upstream
83 |
84 | Gets all headers from the original request and normalizes them.
85 | Normalization occurs by removing the prefix ``HTTP_`` and
86 | replacing and ``_`` by ``-``. Example: ``HTTP_ACCEPT_ENCODING``
87 | becomes ``Accept-Encoding``.
88 |
89 | :param request: The original HTTPRequest instance
90 | :returns: Normalized headers for the upstream
91 | """
92 |
93 | return normalize_request_headers(request)
94 |
95 | def get_request_headers(self):
96 | """
97 | Return request headers that will be sent to upstream.
98 |
99 | The header REMOTE_USER is set to the current user
100 | if the view's add_remote_user property is True
101 | """
102 |
103 | request_headers = self.get_proxy_request_headers(self.request)
104 |
105 | if self.add_remote_user and self.request.user.is_active:
106 | request_headers['REMOTE_USER'] = self.request.user.username
107 | self.log.info("REMOTE_USER set")
108 |
109 | return request_headers
110 |
111 | def _created_proxy_response(self, request, path):
112 | request_payload = request.body
113 |
114 | self.log.debug("Request headers: %s", self.request_headers)
115 |
116 | path = quote_plus(path.encode('utf8'), QUOTE_SAFE)
117 | request_url = (self.upstream + '/' if path and self.upstream[-1] != '/' else self.upstream) + path
118 |
119 | self.log.debug("Request URL: %s", request_url)
120 |
121 | if request.GET:
122 | get_data = encode_items(request.GET.lists())
123 | request_url += '?' + urlencode(get_data)
124 | self.log.debug("Request URL: %s", request_url)
125 |
126 | try:
127 | proxy_response = self.http.urlopen(
128 | request.method,
129 | request_url,
130 | redirect=False,
131 | retries=self.retries,
132 | headers=self.request_headers,
133 | body=request_payload,
134 | decode_content=False,
135 | preload_content=False
136 | )
137 | self.log.debug("Proxy response header: %s", proxy_response.getheaders())
138 | except urllib3.exceptions.HTTPError as error:
139 | self.log.exception(error)
140 | raise
141 |
142 | return proxy_response
143 |
144 | def _replace_host_on_redirect_location(self, request, proxy_response):
145 | location = proxy_response.headers.get('Location')
146 |
147 | if location:
148 | if request.is_secure():
149 | scheme = 'https://'
150 | else:
151 | scheme = 'http://'
152 |
153 | request_host = scheme + request.get_host()
154 |
155 | upstream_host_http = 'http://' + self._parsed_url.netloc
156 | upstream_host_https = 'https://' + self._parsed_url.netloc
157 |
158 | location = location.replace(upstream_host_http, request_host)
159 | location = location.replace(upstream_host_https, request_host)
160 |
161 | proxy_response.headers['Location'] = location
162 |
163 | self.log.debug("Proxy response LOCATION: %s", proxy_response.headers['Location'])
164 |
165 | def _set_content_type(self, request, proxy_response):
166 | content_type = proxy_response.headers.get('Content-Type')
167 |
168 | if not content_type:
169 | content_type = (mimetypes.guess_type(request.path)[0] or self.default_content_type)
170 | proxy_response.headers['Content-Type'] = content_type
171 |
172 | self.log.debug("Proxy response CONTENT-TYPE: %s", proxy_response.headers['Content-Type'])
173 |
174 | def dispatch(self, request, *args, **kwargs):
175 | path = kwargs.get('path', '')
176 |
177 | # This block of code recreates the behavior of the DRF ApiView's dispatch method.
178 | # It's what allows us to tap into DRF functionality like authorization, permissions and throttling.
179 | self.args = args
180 | self.kwargs = kwargs
181 | request = self.initialize_request(request, *args, **kwargs)
182 | self.request = request
183 | self.headers = self.default_response_headers # deprecate?
184 |
185 | try:
186 | self.initial(request, *args, **kwargs)
187 | except Exception as e:
188 | response = self.handle_exception(exc=e)
189 | response = self.finalize_response(request, response, *args, **kwargs)
190 | return response
191 |
192 | # ----- End ApiView block -----
193 |
194 | self.request_headers = self.get_request_headers()
195 |
196 | redirect_to = self._format_path_to_redirect(request)
197 |
198 | if redirect_to:
199 | return redirect(redirect_to)
200 |
201 | proxy_response = self._created_proxy_response(request, path)
202 |
203 | self._replace_host_on_redirect_location(request, proxy_response)
204 | self._set_content_type(request, proxy_response)
205 |
206 | response = get_django_response(proxy_response)
207 |
208 | self.log.debug("RESPONSE RETURNED: %s", response)
209 |
210 | return response
211 |
--------------------------------------------------------------------------------
/tests/test_request.py:
--------------------------------------------------------------------------------
1 |
2 | import sys
3 |
4 | if sys.version_info >= (3, 0, 0):
5 | from urllib.parse import parse_qs
6 | else:
7 | from urlparse import parse_qs
8 |
9 | from django.contrib.auth.models import AnonymousUser, User
10 | from django.test import TestCase, RequestFactory
11 |
12 | from mock import patch
13 |
14 | from urllib3 import Retry
15 |
16 | from drfreverseproxy.views import ProxyView
17 |
18 | from .utils import get_urlopen_mock
19 |
20 |
21 | class RequestTest(TestCase):
22 |
23 | def setUp(self):
24 | # Every test needs access to the request factory.
25 | self.factory = RequestFactory()
26 | self.user = User.objects.create_user(
27 | username='jacob', email='jacob@example.com', password='top_secret')
28 |
29 | urlopen_mock = get_urlopen_mock()
30 | self.urlopen_patcher = patch('urllib3.PoolManager.urlopen',
31 | urlopen_mock)
32 |
33 | self.urlopen = self.urlopen_patcher.start()
34 |
35 | def tearDown(self):
36 | self.urlopen_patcher.stop()
37 |
38 | def test_default_add_remote_user_attr(self):
39 | proxy_view = ProxyView(upstream='http://www.example.com')
40 | self.assertFalse(proxy_view.add_remote_user)
41 |
42 | def factory_custom_proxy_view(self, **kwargs):
43 | class CustomProxyView(ProxyView):
44 | add_remote_user = kwargs.get('add_remote_user', False)
45 | upstream = kwargs.get('upstream', 'http://www.example.com')
46 | retries = kwargs.get('retries', None)
47 | rewrite = kwargs.get('rewrite', tuple())
48 |
49 | if kwargs.get('method') == 'POST':
50 | request = self.factory.post(kwargs.get('path', ''),
51 | kwargs.get('post_data', {}))
52 | elif kwargs.get('method') == 'PUT':
53 | request = self.factory.put('path', kwargs.get('request_data', {}))
54 | else:
55 | request = self.factory.get(kwargs.get('path', ''),
56 | kwargs.get('get_data', {}))
57 |
58 | if kwargs.get('anonymous'):
59 | request.user = AnonymousUser()
60 | else:
61 | request.user = self.user
62 |
63 | if kwargs.get('headers'):
64 | for key, value in kwargs.get('headers').items():
65 | request.META[key] = value
66 |
67 | response = CustomProxyView.as_view()(request, path=kwargs.get('path', ''))
68 | return {'request': request, 'response': response}
69 |
70 | def test_remote_user_authenticated(self):
71 | options = {'add_remote_user': True, 'anonymous': False,
72 | 'path': 'test'}
73 |
74 | self.factory_custom_proxy_view(**options)
75 | url = 'http://www.example.com/test'
76 | headers = {'REMOTE_USER': 'jacob', 'Cookie': ''}
77 | self.urlopen.assert_called_with('GET', url,
78 | redirect=False,
79 | retries=None,
80 | preload_content=False,
81 | decode_content=False,
82 | headers=headers,
83 | body=b'')
84 |
85 | def test_remote_user_anonymous(self):
86 | options = {'add_remote_user': True, 'anonymous': True,
87 | 'path': 'test/anonymous/'}
88 |
89 | self.factory_custom_proxy_view(**options)
90 | url = 'http://www.example.com/test/anonymous/'
91 | headers = {'Cookie': ''}
92 | self.urlopen.assert_called_with('GET', url, redirect=False,
93 | retries=None,
94 | preload_content=False,
95 | decode_content=False,
96 | headers=headers, body=b'')
97 |
98 | def test_custom_retries(self):
99 | RETRIES = Retry(20, backoff_factor=0.1)
100 | options = {'path': 'test/', 'retries': RETRIES}
101 |
102 | self.factory_custom_proxy_view(**options)
103 | url = 'http://www.example.com/test/'
104 | headers = {'Cookie': ''}
105 | self.urlopen.assert_called_with('GET', url, redirect=False,
106 | retries=RETRIES,
107 | preload_content=False,
108 | decode_content=False,
109 | headers=headers, body=b'')
110 |
111 | def test_simple_get(self):
112 | get_data = {'a': ['b'], 'c': ['d'], 'e': ['f']}
113 | options = {'path': 'test/', 'get_data': get_data}
114 |
115 | self.factory_custom_proxy_view(**options)
116 |
117 | assert self.urlopen.called
118 | called_qs = self.urlopen.call_args[0][1].split('?')[-1]
119 | called_get_data = parse_qs(called_qs)
120 | self.assertEqual(called_get_data, get_data)
121 |
122 | def test_get_with_attr_list(self):
123 | get_data = {
124 | u'a': [u'a', u'b', u'c', u'd'],
125 | u'foo': [u'bar'],
126 | }
127 | options = {'path': '/', 'get_data': get_data}
128 | self.factory_custom_proxy_view(**options)
129 |
130 | assert self.urlopen.called
131 | called_qs = self.urlopen.call_args[0][1].split('?')[-1]
132 |
133 | called_get_data = parse_qs(called_qs)
134 | self.assertEqual(called_get_data, get_data)
135 |
136 | def test_post_and_get(self):
137 | get_data = {'x': ['y', 'z']}
138 | post_data = {'a': ['b'], 'c': ['d'], 'e': ['f']}
139 |
140 | options = {'path': '/?x=y&x=z', 'post_data': post_data}
141 | result = self.factory_custom_proxy_view(**options)
142 |
143 | assert self.urlopen.called
144 | called_qs = self.urlopen.call_args[0][1].split('?')[-1]
145 |
146 | # Check for GET data
147 | called_get_data = parse_qs(called_qs)
148 | self.assertEqual(called_get_data, get_data)
149 |
150 | # Check for POST data
151 | self.assertEqual(self.urlopen.call_args[1]['body'],
152 | result.get('request').body)
153 |
154 | # def test_put(self):
155 | # request_data = {'a': ['b'], 'c': ['d'], 'e': ['f']}
156 | #
157 | # options = {'path': '/', 'method': 'PUT', 'request_data': request_data}
158 | #
159 | # result = self.factory_custom_proxy_view(**options)
160 | #
161 | # assert self.urlopen.called
162 | #
163 | # # Check for request data
164 | # self.assertEqual(self.urlopen.call_args[1]['body'],
165 | # result.get('request').body)
166 | #
167 | # self.assertEqual(self.urlopen.call_args[0][0], 'PUT')
168 |
169 | def test_simple_rewrite(self):
170 | rewrite = (
171 | (r'^/yellow/star/?$', r'/black/hole/'),
172 | (r'^/foo/$', r'/bar'),
173 | )
174 | options = {'path': '/yellow/star', 'add_remote_user': False,
175 | 'rewrite': rewrite}
176 |
177 | result = self.factory_custom_proxy_view(**options)
178 | self.assertEqual(result.get('response').url, '/black/hole/')
179 | self.assertEqual(result.get('response').status_code, 302)
180 |
181 | def test_rewrite_with_get(self):
182 | rewrite = (
183 | (r'^/foo/(.*)$', r'/bar\1'),
184 | )
185 | get_data = {'a': ['1'], 'b': ['c']}
186 | options = {'path': '/foo/', 'add_remote_user': False,
187 | 'rewrite': rewrite, 'get_data': get_data}
188 |
189 | result = self.factory_custom_proxy_view(**options)
190 | path, querystring = result.get('response').url.split('?')
191 | self.assertEqual(path, '/bar')
192 | self.assertEqual(result.get('response').status_code, 302)
193 |
194 | response_data = parse_qs(querystring)
195 | self.assertEqual(response_data, get_data)
196 |
197 | def test_rewrite_to_external_location(self):
198 | rewrite = (
199 | (r'^/yellow/star/?$', r'http://www.mozilla.org/'),
200 | )
201 | options = {'path': '/yellow/star', 'add_remote_user': False,
202 | 'rewrite': rewrite}
203 |
204 | result = self.factory_custom_proxy_view(**options)
205 | self.assertEqual(result.get('response').url, 'http://www.mozilla.org/')
206 | self.assertEqual(result.get('response').status_code, 302)
207 |
208 | def test_rewrite_to_view_name(self):
209 | rewrite = (
210 | (r'^/yellow/star/$', r'login'),
211 | )
212 | options = {'path': '/yellow/star/', 'add_remote_user': False,
213 | 'rewrite': rewrite}
214 |
215 | result = self.factory_custom_proxy_view(**options)
216 | self.assertEqual(result.get('response').url, '/accounts/login/')
217 | self.assertEqual(result.get('response').status_code, 302)
218 |
219 | def test_no_rewrite(self):
220 | rewrite = (
221 | (r'^/yellow/star/$', r'login'),
222 | (r'^/foo/(.*)$', r'/bar\1'),
223 | )
224 |
225 | options = {'path': 'test/', 'rewrite': rewrite}
226 |
227 | result = self.factory_custom_proxy_view(**options)
228 | url = 'http://www.example.com/test/'
229 | headers = {'Cookie': ''}
230 | self.urlopen.assert_called_with('GET', url, redirect=False,
231 | retries=None,
232 | preload_content=False,
233 | decode_content=False,
234 | headers=headers, body=b'')
235 |
236 | def test_remote_user_injection_anonymous(self):
237 | request_headers = {'HTTP_REMOTE_USER': 'foo'}
238 | options = {'path': 'test', 'anonymous': True,
239 | 'headers': request_headers}
240 | result = self.factory_custom_proxy_view(**options)
241 |
242 | url = 'http://www.example.com/test'
243 | headers = {'Cookie': ''}
244 | self.urlopen.assert_called_with('GET', url,
245 | redirect=False,
246 | retries=None,
247 | preload_content=False,
248 | decode_content=False,
249 | headers=headers,
250 | body=b'')
251 |
252 | def test_remote_user_injection_authenticated(self):
253 | request_headers = {'HTTP_REMOTE_USER': 'foo'}
254 | options = {'path': 'test', 'headers': request_headers}
255 | result = self.factory_custom_proxy_view(**options)
256 |
257 | url = 'http://www.example.com/test'
258 | headers = {'Cookie': ''}
259 | self.urlopen.assert_called_with('GET', url,
260 | redirect=False,
261 | retries=None,
262 | preload_content=False,
263 | decode_content=False,
264 | headers=headers,
265 | body=b'')
266 |
267 | def test_remote_user_injection_authenticated_add_remote_user(self):
268 | request_headers = {'HTTP_REMOTE_USER': 'foo'}
269 | options = {'path': 'test', 'headers': request_headers,
270 | 'add_remote_user': True}
271 | result = self.factory_custom_proxy_view(**options)
272 |
273 | url = 'http://www.example.com/test'
274 | headers = {'Cookie': '', 'REMOTE_USER': 'jacob'}
275 | self.urlopen.assert_called_with('GET', url,
276 | redirect=False,
277 | retries=None,
278 | preload_content=False,
279 | decode_content=False,
280 | headers=headers,
281 | body=b'')
282 |
283 | def test_remote_user_injection_anonymous_add_remote_user(self):
284 | request_headers = {'HTTP_REMOTE_USER': 'foo'}
285 | options = {'path': 'test', 'headers': request_headers,
286 | 'add_remote_user': True, 'anonymous': True}
287 | result = self.factory_custom_proxy_view(**options)
288 |
289 | url = 'http://www.example.com/test'
290 | headers = {'Cookie': ''}
291 | self.urlopen.assert_called_with('GET', url,
292 | redirect=False,
293 | retries=None,
294 | preload_content=False,
295 | decode_content=False,
296 | headers=headers,
297 | body=b'')
298 |
299 | def test_set_custom_headers(self):
300 | request_headers = {'HTTP_DNT': 1}
301 | url = 'http://www.example.com'
302 |
303 | request = self.factory.get('')
304 | for key, value in request_headers.items():
305 | request.META[key] = value
306 |
307 | class CustomProxyView(ProxyView):
308 | upstream = url
309 |
310 | def get_request_headers(self):
311 | request_headers = super(CustomProxyView,
312 | self).get_request_headers()
313 | request_headers['Host'] = 'foo.bar'
314 | return request_headers
315 |
316 | CustomProxyView.as_view()(request, path='')
317 |
318 | headers = {'Dnt': 1, 'Cookie': '', 'Host': 'foo.bar'}
319 |
320 | self.urlopen.assert_called_with('GET', url,
321 | redirect=False,
322 | retries=None,
323 | preload_content=False,
324 | decode_content=False,
325 | headers=headers,
326 | body=b'')
327 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Mozilla Public License Version 2.0
2 | ==================================
3 |
4 | 1. Definitions
5 | --------------
6 |
7 | 1.1. "Contributor"
8 | means each individual or legal entity that creates, contributes to
9 | the creation of, or owns Covered Software.
10 |
11 | 1.2. "Contributor Version"
12 | means the combination of the Contributions of others (if any) used
13 | by a Contributor and that particular Contributor's Contribution.
14 |
15 | 1.3. "Contribution"
16 | means Covered Software of a particular Contributor.
17 |
18 | 1.4. "Covered Software"
19 | means Source Code Form to which the initial Contributor has attached
20 | the notice in Exhibit A, the Executable Form of such Source Code
21 | Form, and Modifications of such Source Code Form, in each case
22 | including portions thereof.
23 |
24 | 1.5. "Incompatible With Secondary Licenses"
25 | means
26 |
27 | (a) that the initial Contributor has attached the notice described
28 | in Exhibit B to the Covered Software; or
29 |
30 | (b) that the Covered Software was made available under the terms of
31 | version 1.1 or earlier of the License, but not also under the
32 | terms of a Secondary License.
33 |
34 | 1.6. "Executable Form"
35 | means any form of the work other than Source Code Form.
36 |
37 | 1.7. "Larger Work"
38 | means a work that combines Covered Software with other material, in
39 | a separate file or files, that is not Covered Software.
40 |
41 | 1.8. "License"
42 | means this document.
43 |
44 | 1.9. "Licensable"
45 | means having the right to grant, to the maximum extent possible,
46 | whether at the time of the initial grant or subsequently, any and
47 | all of the rights conveyed by this License.
48 |
49 | 1.10. "Modifications"
50 | means any of the following:
51 |
52 | (a) any file in Source Code Form that results from an addition to,
53 | deletion from, or modification of the contents of Covered
54 | Software; or
55 |
56 | (b) any new file in Source Code Form that contains any Covered
57 | Software.
58 |
59 | 1.11. "Patent Claims" of a Contributor
60 | means any patent claim(s), including without limitation, method,
61 | process, and apparatus claims, in any patent Licensable by such
62 | Contributor that would be infringed, but for the grant of the
63 | License, by the making, using, selling, offering for sale, having
64 | made, import, or transfer of either its Contributions or its
65 | Contributor Version.
66 |
67 | 1.12. "Secondary License"
68 | means either the GNU General Public License, Version 2.0, the GNU
69 | Lesser General Public License, Version 2.1, the GNU Affero General
70 | Public License, Version 3.0, or any later versions of those
71 | licenses.
72 |
73 | 1.13. "Source Code Form"
74 | means the form of the work preferred for making modifications.
75 |
76 | 1.14. "You" (or "Your")
77 | means an individual or a legal entity exercising rights under this
78 | License. For legal entities, "You" includes any entity that
79 | controls, is controlled by, or is under common control with You. For
80 | purposes of this definition, "control" means (a) the power, direct
81 | or indirect, to cause the direction or management of such entity,
82 | whether by contract or otherwise, or (b) ownership of more than
83 | fifty percent (50%) of the outstanding shares or beneficial
84 | ownership of such entity.
85 |
86 | 2. License Grants and Conditions
87 | --------------------------------
88 |
89 | 2.1. Grants
90 |
91 | Each Contributor hereby grants You a world-wide, royalty-free,
92 | non-exclusive license:
93 |
94 | (a) under intellectual property rights (other than patent or trademark)
95 | Licensable by such Contributor to use, reproduce, make available,
96 | modify, display, perform, distribute, and otherwise exploit its
97 | Contributions, either on an unmodified basis, with Modifications, or
98 | as part of a Larger Work; and
99 |
100 | (b) under Patent Claims of such Contributor to make, use, sell, offer
101 | for sale, have made, import, and otherwise transfer either its
102 | Contributions or its Contributor Version.
103 |
104 | 2.2. Effective Date
105 |
106 | The licenses granted in Section 2.1 with respect to any Contribution
107 | become effective for each Contribution on the date the Contributor first
108 | distributes such Contribution.
109 |
110 | 2.3. Limitations on Grant Scope
111 |
112 | The licenses granted in this Section 2 are the only rights granted under
113 | this License. No additional rights or licenses will be implied from the
114 | distribution or licensing of Covered Software under this License.
115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a
116 | Contributor:
117 |
118 | (a) for any code that a Contributor has removed from Covered Software;
119 | or
120 |
121 | (b) for infringements caused by: (i) Your and any other third party's
122 | modifications of Covered Software, or (ii) the combination of its
123 | Contributions with other software (except as part of its Contributor
124 | Version); or
125 |
126 | (c) under Patent Claims infringed by Covered Software in the absence of
127 | its Contributions.
128 |
129 | This License does not grant any rights in the trademarks, service marks,
130 | or logos of any Contributor (except as may be necessary to comply with
131 | the notice requirements in Section 3.4).
132 |
133 | 2.4. Subsequent Licenses
134 |
135 | No Contributor makes additional grants as a result of Your choice to
136 | distribute the Covered Software under a subsequent version of this
137 | License (see Section 10.2) or under the terms of a Secondary License (if
138 | permitted under the terms of Section 3.3).
139 |
140 | 2.5. Representation
141 |
142 | Each Contributor represents that the Contributor believes its
143 | Contributions are its original creation(s) or it has sufficient rights
144 | to grant the rights to its Contributions conveyed by this License.
145 |
146 | 2.6. Fair Use
147 |
148 | This License is not intended to limit any rights You have under
149 | applicable copyright doctrines of fair use, fair dealing, or other
150 | equivalents.
151 |
152 | 2.7. Conditions
153 |
154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155 | in Section 2.1.
156 |
157 | 3. Responsibilities
158 | -------------------
159 |
160 | 3.1. Distribution of Source Form
161 |
162 | All distribution of Covered Software in Source Code Form, including any
163 | Modifications that You create or to which You contribute, must be under
164 | the terms of this License. You must inform recipients that the Source
165 | Code Form of the Covered Software is governed by the terms of this
166 | License, and how they can obtain a copy of this License. You may not
167 | attempt to alter or restrict the recipients' rights in the Source Code
168 | Form.
169 |
170 | 3.2. Distribution of Executable Form
171 |
172 | If You distribute Covered Software in Executable Form then:
173 |
174 | (a) such Covered Software must also be made available in Source Code
175 | Form, as described in Section 3.1, and You must inform recipients of
176 | the Executable Form how they can obtain a copy of such Source Code
177 | Form by reasonable means in a timely manner, at a charge no more
178 | than the cost of distribution to the recipient; and
179 |
180 | (b) You may distribute such Executable Form under the terms of this
181 | License, or sublicense it under different terms, provided that the
182 | license for the Executable Form does not attempt to limit or alter
183 | the recipients' rights in the Source Code Form under this License.
184 |
185 | 3.3. Distribution of a Larger Work
186 |
187 | You may create and distribute a Larger Work under terms of Your choice,
188 | provided that You also comply with the requirements of this License for
189 | the Covered Software. If the Larger Work is a combination of Covered
190 | Software with a work governed by one or more Secondary Licenses, and the
191 | Covered Software is not Incompatible With Secondary Licenses, this
192 | License permits You to additionally distribute such Covered Software
193 | under the terms of such Secondary License(s), so that the recipient of
194 | the Larger Work may, at their option, further distribute the Covered
195 | Software under the terms of either this License or such Secondary
196 | License(s).
197 |
198 | 3.4. Notices
199 |
200 | You may not remove or alter the substance of any license notices
201 | (including copyright notices, patent notices, disclaimers of warranty,
202 | or limitations of liability) contained within the Source Code Form of
203 | the Covered Software, except that You may alter any license notices to
204 | the extent required to remedy known factual inaccuracies.
205 |
206 | 3.5. Application of Additional Terms
207 |
208 | You may choose to offer, and to charge a fee for, warranty, support,
209 | indemnity or liability obligations to one or more recipients of Covered
210 | Software. However, You may do so only on Your own behalf, and not on
211 | behalf of any Contributor. You must make it absolutely clear that any
212 | such warranty, support, indemnity, or liability obligation is offered by
213 | You alone, and You hereby agree to indemnify every Contributor for any
214 | liability incurred by such Contributor as a result of warranty, support,
215 | indemnity or liability terms You offer. You may include additional
216 | disclaimers of warranty and limitations of liability specific to any
217 | jurisdiction.
218 |
219 | 4. Inability to Comply Due to Statute or Regulation
220 | ---------------------------------------------------
221 |
222 | If it is impossible for You to comply with any of the terms of this
223 | License with respect to some or all of the Covered Software due to
224 | statute, judicial order, or regulation then You must: (a) comply with
225 | the terms of this License to the maximum extent possible; and (b)
226 | describe the limitations and the code they affect. Such description must
227 | be placed in a text file included with all distributions of the Covered
228 | Software under this License. Except to the extent prohibited by statute
229 | or regulation, such description must be sufficiently detailed for a
230 | recipient of ordinary skill to be able to understand it.
231 |
232 | 5. Termination
233 | --------------
234 |
235 | 5.1. The rights granted under this License will terminate automatically
236 | if You fail to comply with any of its terms. However, if You become
237 | compliant, then the rights granted under this License from a particular
238 | Contributor are reinstated (a) provisionally, unless and until such
239 | Contributor explicitly and finally terminates Your grants, and (b) on an
240 | ongoing basis, if such Contributor fails to notify You of the
241 | non-compliance by some reasonable means prior to 60 days after You have
242 | come back into compliance. Moreover, Your grants from a particular
243 | Contributor are reinstated on an ongoing basis if such Contributor
244 | notifies You of the non-compliance by some reasonable means, this is the
245 | first time You have received notice of non-compliance with this License
246 | from such Contributor, and You become compliant prior to 30 days after
247 | Your receipt of the notice.
248 |
249 | 5.2. If You initiate litigation against any entity by asserting a patent
250 | infringement claim (excluding declaratory judgment actions,
251 | counter-claims, and cross-claims) alleging that a Contributor Version
252 | directly or indirectly infringes any patent, then the rights granted to
253 | You by any and all Contributors for the Covered Software under Section
254 | 2.1 of this License shall terminate.
255 |
256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257 | end user license agreements (excluding distributors and resellers) which
258 | have been validly granted by You or Your distributors under this License
259 | prior to termination shall survive termination.
260 |
261 | ************************************************************************
262 | * *
263 | * 6. Disclaimer of Warranty *
264 | * ------------------------- *
265 | * *
266 | * Covered Software is provided under this License on an "as is" *
267 | * basis, without warranty of any kind, either expressed, implied, or *
268 | * statutory, including, without limitation, warranties that the *
269 | * Covered Software is free of defects, merchantable, fit for a *
270 | * particular purpose or non-infringing. The entire risk as to the *
271 | * quality and performance of the Covered Software is with You. *
272 | * Should any Covered Software prove defective in any respect, You *
273 | * (not any Contributor) assume the cost of any necessary servicing, *
274 | * repair, or correction. This disclaimer of warranty constitutes an *
275 | * essential part of this License. No use of any Covered Software is *
276 | * authorized under this License except under this disclaimer. *
277 | * *
278 | ************************************************************************
279 |
280 | ************************************************************************
281 | * *
282 | * 7. Limitation of Liability *
283 | * -------------------------- *
284 | * *
285 | * Under no circumstances and under no legal theory, whether tort *
286 | * (including negligence), contract, or otherwise, shall any *
287 | * Contributor, or anyone who distributes Covered Software as *
288 | * permitted above, be liable to You for any direct, indirect, *
289 | * special, incidental, or consequential damages of any character *
290 | * including, without limitation, damages for lost profits, loss of *
291 | * goodwill, work stoppage, computer failure or malfunction, or any *
292 | * and all other commercial damages or losses, even if such party *
293 | * shall have been informed of the possibility of such damages. This *
294 | * limitation of liability shall not apply to liability for death or *
295 | * personal injury resulting from such party's negligence to the *
296 | * extent applicable law prohibits such limitation. Some *
297 | * jurisdictions do not allow the exclusion or limitation of *
298 | * incidental or consequential damages, so this exclusion and *
299 | * limitation may not apply to You. *
300 | * *
301 | ************************************************************************
302 |
303 | 8. Litigation
304 | -------------
305 |
306 | Any litigation relating to this License may be brought only in the
307 | courts of a jurisdiction where the defendant maintains its principal
308 | place of business and such litigation shall be governed by laws of that
309 | jurisdiction, without reference to its conflict-of-law provisions.
310 | Nothing in this Section shall prevent a party's ability to bring
311 | cross-claims or counter-claims.
312 |
313 | 9. Miscellaneous
314 | ----------------
315 |
316 | This License represents the complete agreement concerning the subject
317 | matter hereof. If any provision of this License is held to be
318 | unenforceable, such provision shall be reformed only to the extent
319 | necessary to make it enforceable. Any law or regulation which provides
320 | that the language of a contract shall be construed against the drafter
321 | shall not be used to construe this License against a Contributor.
322 |
323 | 10. Versions of the License
324 | ---------------------------
325 |
326 | 10.1. New Versions
327 |
328 | Mozilla Foundation is the license steward. Except as provided in Section
329 | 10.3, no one other than the license steward has the right to modify or
330 | publish new versions of this License. Each version will be given a
331 | distinguishing version number.
332 |
333 | 10.2. Effect of New Versions
334 |
335 | You may distribute the Covered Software under the terms of the version
336 | of the License under which You originally received the Covered Software,
337 | or under the terms of any subsequent version published by the license
338 | steward.
339 |
340 | 10.3. Modified Versions
341 |
342 | If you create software not governed by this License, and you want to
343 | create a new license for such software, you may create and use a
344 | modified version of this License if you rename the license and remove
345 | any references to the name of the license steward (except to note that
346 | such modified license differs from this License).
347 |
348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary
349 | Licenses
350 |
351 | If You choose to distribute Source Code Form that is Incompatible With
352 | Secondary Licenses under the terms of this version of the License, the
353 | notice described in Exhibit B of this License must be attached.
354 |
355 | Exhibit A - Source Code Form License Notice
356 | -------------------------------------------
357 |
358 | This Source Code Form is subject to the terms of the Mozilla Public
359 | License, v. 2.0. If a copy of the MPL was not distributed with this
360 | file, You can obtain one at http://mozilla.org/MPL/2.0/.
361 |
362 | If it is not possible or desirable to put the notice in a particular
363 | file, then You may include the notice in a location (such as a LICENSE
364 | file in a relevant directory) where a recipient would be likely to look
365 | for such a notice.
366 |
367 | You may add additional accurate notices of copyright ownership.
368 |
369 | Exhibit B - "Incompatible With Secondary Licenses" Notice
370 | ---------------------------------------------------------
371 |
372 | This Source Code Form is "Incompatible With Secondary Licenses", as
373 | defined by the Mozilla Public License, v. 2.0.
--------------------------------------------------------------------------------