├── .github └── workflows │ └── test.yml ├── .gitignore ├── .hgignore ├── LICENCE ├── MANIFEST.in ├── README.rst ├── django_push ├── __init__.py ├── publisher │ ├── __init__.py │ └── feeds.py └── subscriber │ ├── __init__.py │ ├── admin.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── signals.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── docs ├── Makefile ├── conf.py ├── index.rst ├── publisher.rst └── subscriber.rst ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── publisher │ ├── __init__.py │ ├── feeds.py │ ├── models.py │ ├── tests.py │ └── urls.py ├── settings.py └── subscribe │ ├── __init__.py │ ├── models.py │ └── tests.py └── tox.ini /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | max-parallel: 5 13 | matrix: 14 | python-version: ['3.8', '3.9', '3.10', '3.11'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Get pip cache dir 25 | id: pip-cache 26 | run: | 27 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 28 | 29 | - name: Cache 30 | uses: actions/cache@v3 31 | with: 32 | path: ${{ steps.pip-cache.outputs.dir }} 33 | key: 34 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 35 | restore-keys: | 36 | ${{ matrix.python-version }}-v1- 37 | 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | python -m pip install --upgrade tox tox-gh-actions 42 | 43 | - name: Tox tests 44 | run: | 45 | tox --verbose 46 | 47 | - name: Upload coverage data 48 | uses: actions/upload-artifact@v3 49 | with: 50 | name: coverage-data 51 | path: ".coverage" 52 | 53 | coverage: 54 | name: Check coverage. 55 | runs-on: "ubuntu-latest" 56 | needs: [tests] 57 | steps: 58 | - uses: actions/checkout@v3 59 | - uses: actions/setup-python@v4 60 | with: 61 | # Use latest, so it understands all syntax. 62 | python-version: "3.11" 63 | 64 | - run: python -m pip install --upgrade coverage 65 | 66 | - name: Download coverage data. 67 | uses: actions/download-artifact@v3 68 | with: 69 | name: coverage-data 70 | 71 | - name: Combine coverage & check percentage 72 | run: | 73 | python -m coverage html 74 | python -m coverage report 75 | 76 | - name: Upload HTML report if check failed. 77 | uses: actions/upload-artifact@v3 78 | with: 79 | name: html-report 80 | path: htmlcov 81 | if: ${{ failure() }} 82 | 83 | lint: 84 | runs-on: ubuntu-latest 85 | strategy: 86 | fail-fast: false 87 | 88 | steps: 89 | - uses: actions/checkout@v3 90 | 91 | - name: Set up Python ${{ matrix.python-version }} 92 | uses: actions/setup-python@v4 93 | with: 94 | python-version: 3.11 95 | 96 | - name: Get pip cache dir 97 | id: pip-cache 98 | run: | 99 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 100 | 101 | - name: Cache 102 | uses: actions/cache@v3 103 | with: 104 | path: ${{ steps.pip-cache.outputs.dir }} 105 | key: 106 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 107 | restore-keys: | 108 | ${{ matrix.python-version }}-v1- 109 | 110 | - name: Install dependencies 111 | run: | 112 | python -m pip install --upgrade pip 113 | python -m pip install --upgrade tox 114 | 115 | - name: Test with tox 116 | run: tox -e docs,lint 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | dist 4 | .coverage 5 | htmlcov 6 | .tox 7 | build 8 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | .pyc 2 | .venv 3 | dist 4 | django_push.egg-info 5 | push.sqlite3 6 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2023, Bruno Renié and individual contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of this project nor the names of its contributors may be 15 | used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-exclude tests * 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django-PuSH 2 | =========== 3 | 4 | .. image:: https://travis-ci.org/brutasse/django-push.png?branch=master 5 | :alt: Build Status 6 | :target: https://travis-ci.org/brutasse/django-push 7 | 8 | PubSubHubbub support for Django. 9 | 10 | * Author: Bruno Renié and `contributors`_ 11 | * Licence: BSD 12 | 13 | .. _contributors: https://github.com/brutasse/django-push/contributors 14 | 15 | Usage 16 | ----- 17 | 18 | The documentation is `available on ReadTheDocs`_. 19 | 20 | .. _available on ReadTheDocs: https://django-push.readthedocs.io/ 21 | 22 | Contributing 23 | ------------ 24 | 25 | Links to the PubSubHubbub specs: `0.4`_, `0.3`_. 26 | 27 | .. _0.4: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.4.html 28 | .. _0.3: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html 29 | 30 | * The project is on github: https://github.com/brutasse/django-push 31 | * To setup a development environment, run:: 32 | 33 | mkvirtualenv django-push 34 | pip install -r requirements-dev.txt Django 35 | 36 | Then run the tests:: 37 | 38 | python setup.py test 39 | 40 | Use ``tox`` to run the tests across all supported Python / Django version 41 | combinations:: 42 | 43 | pip install tox 44 | tox 45 | 46 | To get code coverage stats:: 47 | 48 | pip install coverage 49 | coverage run runtests.py 50 | coverage html 51 | open htmlcov/index.html 52 | -------------------------------------------------------------------------------- /django_push/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.0' 2 | 3 | UA = 'django-push/{0}'.format(__version__) 4 | -------------------------------------------------------------------------------- /django_push/publisher/__init__.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from django.conf import settings 4 | 5 | from .. import UA 6 | 7 | 8 | def ping_hub(feed_url, hub_url=None): 9 | """ 10 | Makes a POST request to the hub. If no hub_url is provided, the 11 | value is fetched from the PUSH_HUB setting. 12 | 13 | Returns a `requests.models.Response` object. 14 | """ 15 | if hub_url is None: 16 | hub_url = getattr(settings, 'PUSH_HUB', None) 17 | if hub_url is None: 18 | raise ValueError("Specify hub_url or set the PUSH_HUB setting.") 19 | params = { 20 | 'hub.mode': 'publish', 21 | 'hub.url': feed_url, 22 | } 23 | return requests.post(hub_url, data=params, headers={'User-Agent': UA}) 24 | -------------------------------------------------------------------------------- /django_push/publisher/feeds.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.syndication.views import Feed as BaseFeed 3 | from django.utils.feedgenerator import Atom1Feed 4 | 5 | 6 | class HubAtom1Feed(Atom1Feed): 7 | def add_root_elements(self, handler): 8 | super().add_root_elements(handler) 9 | 10 | hub = self.feed.get('hub') 11 | if hub is not None: 12 | handler.addQuickElement('link', '', {'rel': 'hub', 13 | 'href': hub}) 14 | 15 | 16 | class Feed(BaseFeed): 17 | feed_type = HubAtom1Feed 18 | hub = None 19 | 20 | def get_hub(self, obj): 21 | if self.hub is None: 22 | hub = getattr(settings, 'PUSH_HUB', None) 23 | else: 24 | hub = self.hub 25 | return hub 26 | 27 | def feed_extra_kwargs(self, obj): 28 | kwargs = super().feed_extra_kwargs(obj) 29 | kwargs['hub'] = self.get_hub(obj) 30 | return kwargs 31 | -------------------------------------------------------------------------------- /django_push/subscriber/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/django-push/22fda99641cfbd2f3075a723d92652a8e38220a5/django_push/subscriber/__init__.py -------------------------------------------------------------------------------- /django_push/subscriber/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.utils import timezone 3 | from django.utils.translation import gettext_lazy as _, ngettext 4 | 5 | from django_push.subscriber.models import Subscription, SubscriptionError 6 | 7 | 8 | class ExpirationFilter(admin.SimpleListFilter): 9 | title = _('Expired') 10 | parameter_name = 'expired' 11 | 12 | def lookups(self, request, model_admin): 13 | return ( 14 | ('true', _('Yes')), 15 | ('false', _('No')), 16 | ) 17 | 18 | def queryset(self, request, queryset): 19 | if self.value() == 'true': 20 | return queryset.filter(lease_expiration__lte=timezone.now()) 21 | if self.value() == 'false': 22 | return queryset.filter(lease_expiration__gte=timezone.now()) 23 | 24 | 25 | class SubscriptionAmin(admin.ModelAdmin): 26 | list_display = ('truncated_topic', 'hub', 'verified', 'has_expired', 27 | 'lease_expiration') 28 | list_filter = ('verified', ExpirationFilter, 'hub') 29 | search_fields = ('topic', 'hub') 30 | actions = ['renew', 'unsubscribe'] 31 | readonly_fields = ['callback_url'] 32 | 33 | def renew(self, request, queryset): 34 | count = 0 35 | failed = 0 36 | for subscription in queryset: 37 | try: 38 | subscription.subscribe() 39 | count += 1 40 | except SubscriptionError: 41 | failed += 1 42 | if count: 43 | message = ngettext( 44 | '%s subscription was successfully renewed.', 45 | '%s subscriptions were successfully renewd.', 46 | count) % count 47 | self.message_user(request, message) 48 | if failed: 49 | message = ngettext( 50 | 'Failed to renew %s subscription.', 51 | 'Failed to renew %s subscriptions.', 52 | failed) % failed 53 | self.message_user(request, message, level=messages.ERROR) 54 | renew.short_description = _('Renew selected subscriptions') 55 | 56 | def unsubscribe(self, request, queryset): 57 | count = 0 58 | failed = 0 59 | for subscription in queryset: 60 | try: 61 | subscription.unsubscribe() 62 | count += 1 63 | except SubscriptionError: 64 | failed += 1 65 | if count: 66 | message = ngettext( 67 | 'Successfully unsubscribed from %s topic.', 68 | 'Successfully unsubscribed from %s topics.', 69 | count) % count 70 | self.message_user(request, message) 71 | if failed: 72 | message = ngettext( 73 | 'Failed to unsubscribe from %s topic.', 74 | 'Failed to unsubscribe from %s topics.', 75 | failed) % failed 76 | self.message_user(request, message, level=messages.ERROR) 77 | unsubscribe.short_description = _('Unsubscribe from selected topics') 78 | 79 | 80 | admin.site.register(Subscription, SubscriptionAmin) 81 | -------------------------------------------------------------------------------- /django_push/subscriber/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ] 8 | 9 | operations = [ 10 | migrations.CreateModel( 11 | name='Subscription', 12 | fields=[ 13 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 14 | ('hub', models.URLField(max_length=1023, verbose_name='Hub')), 15 | ('topic', models.URLField(max_length=1023, verbose_name='Topic')), 16 | ('verified', models.BooleanField(default=False, verbose_name='Verified')), 17 | ('verify_token', models.CharField(max_length=255, verbose_name='Verify Token', blank=True)), 18 | ('lease_expiration', models.DateTimeField(null=True, verbose_name='Lease expiration', blank=True)), 19 | ('secret', models.CharField(max_length=255, verbose_name='Secret', blank=True)), 20 | ], 21 | options={ 22 | }, 23 | bases=(models.Model,), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_push/subscriber/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/django-push/22fda99641cfbd2f3075a723d92652a8e38220a5/django_push/subscriber/migrations/__init__.py -------------------------------------------------------------------------------- /django_push/subscriber/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from datetime import timedelta 4 | from urllib.parse import urlparse 5 | 6 | import requests 7 | 8 | from django.conf import settings 9 | from django.db import models, transaction 10 | from django.urls import reverse 11 | from django.utils import timezone 12 | from django.utils.translation import gettext_lazy as _ 13 | 14 | from .utils import get_hub_credentials, generate_random_string, get_domain 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class SubscriptionError(Exception): 20 | pass 21 | 22 | 23 | class SubscriptionManager(models.Manager): 24 | def subscribe(self, topic, hub, lease_seconds=None): 25 | # Only use a secret over HTTPS 26 | scheme = urlparse(hub).scheme 27 | defaults = {} 28 | if scheme == 'https': 29 | defaults['secret'] = generate_random_string() 30 | 31 | subscription, created = self.get_or_create(hub=hub, topic=topic, 32 | defaults=defaults) 33 | 34 | # If this code runs in a @transaction.atomic block and the Subscription 35 | # object is created above, it isn't available until the transaction 36 | # commits. At that point, it's safe to send a subscription request 37 | # which then pings back to the the Subscription object. 38 | def subscribe(): 39 | subscription.subscribe(lease_seconds=lease_seconds) 40 | 41 | transaction.on_commit(subscribe) 42 | return subscription 43 | 44 | 45 | class Subscription(models.Model): 46 | hub = models.URLField(_('Hub'), max_length=1023) 47 | topic = models.URLField(_('Topic'), max_length=1023) 48 | verified = models.BooleanField(_('Verified'), default=False) 49 | verify_token = models.CharField(_('Verify Token'), max_length=255, 50 | blank=True) 51 | lease_expiration = models.DateTimeField(_('Lease expiration'), 52 | null=True, blank=True) 53 | secret = models.CharField(_('Secret'), max_length=255, blank=True) 54 | 55 | objects = SubscriptionManager() 56 | 57 | def __str__(self): 58 | return '%s: %s' % (self.topic, self.hub) 59 | 60 | def set_expiration(self, seconds): 61 | self.lease_expiration = timezone.now() + timedelta(seconds=seconds) 62 | 63 | def has_expired(self): 64 | if self.lease_expiration: 65 | return timezone.now() > self.lease_expiration 66 | return False 67 | has_expired.boolean = True 68 | 69 | def truncated_topic(self): 70 | if len(self.topic) > 50: 71 | return self.topic[:49] + '…' 72 | return self.topic 73 | truncated_topic.short_description = _('Topic') 74 | truncated_topic.admin_order_field = 'topic' 75 | 76 | @property 77 | def callback_url(self): 78 | callback_url = reverse('subscriber_callback', args=[self.pk]) 79 | use_ssl = getattr(settings, 'PUSH_SSL_CALLBACK', False) 80 | scheme = 'https' if use_ssl else 'http' 81 | return '%s://%s%s' % (scheme, get_domain(), callback_url) 82 | 83 | def subscribe(self, lease_seconds=None): 84 | return self.send_request(mode='subscribe', lease_seconds=lease_seconds) 85 | 86 | def unsubscribe(self): 87 | return self.send_request(mode='unsubscribe') 88 | 89 | def send_request(self, mode, lease_seconds=None): 90 | params = { 91 | 'hub.mode': mode, 92 | 'hub.callback': self.callback_url, 93 | 'hub.topic': self.topic, 94 | 'hub.verify': ['sync', 'async'], 95 | } 96 | 97 | if self.secret: 98 | params['hub.secret'] = self.secret 99 | 100 | if lease_seconds is None: 101 | lease_seconds = getattr(settings, 'PUSH_LEASE_SECONDS', None) 102 | 103 | # If not provided, let the hub decide. 104 | if lease_seconds is not None: 105 | params['hub.lease_seconds'] = lease_seconds 106 | 107 | credentials = get_hub_credentials(self.hub) 108 | timeout = getattr(settings, 'PUSH_TIMEOUT', None) 109 | response = requests.post(self.hub, data=params, auth=credentials, 110 | timeout=timeout) 111 | 112 | if response.status_code in (202, 204): 113 | if ( 114 | mode == 'subscribe' and 115 | response.status_code == 204 # synchronous verification (0.3) 116 | ): 117 | self.verified = True 118 | Subscription.objects.filter(pk=self.pk).update(verified=True) 119 | 120 | elif response.status_code == 202: 121 | if mode == 'unsubscribe': 122 | self.pending_unsubscription = True 123 | # TODO check for making sure unsubscriptions are legit 124 | # Subscription.objects.filter(pk=self.pk).update( 125 | # pending_unsubscription=True) 126 | return response 127 | 128 | raise SubscriptionError( 129 | "Error during request to hub {0} for topic {1}: {2}".format( 130 | self.hub, self.topic, response.text), 131 | self, 132 | response, 133 | ) 134 | -------------------------------------------------------------------------------- /django_push/subscriber/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | updated = Signal() 4 | -------------------------------------------------------------------------------- /django_push/subscriber/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import callback 4 | 5 | 6 | urlpatterns = [ 7 | path('/', callback, name='subscriber_callback'), 8 | ] 9 | -------------------------------------------------------------------------------- /django_push/subscriber/utils.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from importlib import import_module 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.utils.crypto import get_random_string 7 | 8 | 9 | generate_random_string = partial( 10 | get_random_string, 11 | length=50, 12 | allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' 13 | '0123456789!@#$%^&*(-_=+)') 14 | 15 | 16 | def hub_credentials(hub_url): 17 | """A callback that returns no credentials, for anonymous 18 | subscriptions. Meant to be overriden if developers need to 19 | authenticate with certain hubs""" 20 | return 21 | 22 | 23 | def get_hub_credentials(hub_url): 24 | creds_path = getattr(settings, 'PUSH_CREDENTIALS', 25 | 'django_push.subscriber.utils.hub_credentials') 26 | creds_path, creds_function = creds_path.rsplit('.', 1) 27 | creds_module = import_module(creds_path) 28 | return getattr(creds_module, creds_function)(hub_url) 29 | 30 | 31 | def get_domain(): 32 | if hasattr(settings, 'PUSH_DOMAIN'): 33 | return settings.PUSH_DOMAIN 34 | elif 'django.contrib.sites' in settings.INSTALLED_APPS: 35 | from django.contrib.sites.models import Site 36 | return Site.objects.get_current().domain 37 | raise ImproperlyConfigured( 38 | "Unable to deterermine the site's host. Either use " 39 | "django.contrib.sites and set SITE_ID in your settings or " 40 | "set PUSH_DOMAIN to your site's domain.") 41 | -------------------------------------------------------------------------------- /django_push/subscriber/views.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import logging 4 | 5 | from django.http import HttpResponse, HttpResponseBadRequest 6 | from django.shortcuts import get_object_or_404 7 | from django.utils.decorators import method_decorator 8 | from django.views import generic 9 | from django.views.decorators.csrf import csrf_exempt 10 | from requests.utils import parse_header_links 11 | 12 | from .models import Subscription 13 | from .signals import updated 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class CallbackView(generic.View): 19 | @method_decorator(csrf_exempt) 20 | def dispatch(self, *args, **kwargs): 21 | return super().dispatch(*args, **kwargs) 22 | 23 | def get(self, request, pk, *args, **kwargs): 24 | subscription = get_object_or_404(Subscription, pk=pk) 25 | params = ['hub.mode', 'hub.topic', 'hub.challenge'] 26 | missing = [p for p in params if p not in request.GET] 27 | if missing: 28 | return HttpResponseBadRequest("Missing parameters: {0}".format( 29 | ", ".join(missing))) 30 | 31 | topic = request.GET['hub.topic'] 32 | if not topic == subscription.topic: 33 | return HttpResponseBadRequest("Mismatching topic URL") 34 | 35 | mode = request.GET['hub.mode'] 36 | 37 | if mode not in ['subscribe', 'unsubscribe', 'denied']: 38 | return HttpResponseBadRequest("Unrecognized hub.mode parameter") 39 | 40 | if mode == 'subscribe': 41 | if 'hub.lease_seconds' not in request.GET: 42 | return HttpResponseBadRequest( 43 | "Missing hub.lease_seconds parameter") 44 | 45 | if not request.GET['hub.lease_seconds'].isdigit(): 46 | return HttpResponseBadRequest( 47 | "hub.lease_seconds must be an integer") 48 | 49 | seconds = int(request.GET['hub.lease_seconds']) 50 | subscription.set_expiration(seconds) 51 | subscription.verified = True 52 | logger.debug("Verifying subscription for topic {0} via {1} " 53 | "(expires in {2}s)".format(subscription.topic, 54 | subscription.hub, 55 | seconds)) 56 | Subscription.objects.filter(pk=subscription.pk).update( 57 | verified=True, 58 | lease_expiration=subscription.lease_expiration) 59 | 60 | if mode == 'unsubscribe': 61 | # TODO make sure it was pending deletion 62 | logger.debug("Deleting subscription for topic {0} via {1}".format( 63 | subscription.topic, subscription.hub)) 64 | subscription.delete() 65 | 66 | # TODO handle denied subscriptions 67 | 68 | return HttpResponse(request.GET['hub.challenge']) 69 | 70 | def post(self, request, pk, *args, **kwargs): 71 | subscription = get_object_or_404(Subscription, pk=pk) 72 | 73 | if subscription.secret: 74 | signature = request.META.get('HTTP_X_HUB_SIGNATURE', None) 75 | if signature is None: 76 | logger.debug("Ignoring payload for subscription {0}, missing " 77 | "signature".format(subscription.pk)) 78 | return HttpResponse('') 79 | 80 | hasher = hmac.new(subscription.secret.encode('utf-8'), 81 | request.body, 82 | hashlib.sha1) 83 | digest = 'sha1=%s' % hasher.hexdigest() 84 | if signature != digest: 85 | logger.debug("Mismatching signature for subscription {0}: " 86 | "got {1}, expected {2}".format(subscription.pk, 87 | signature, 88 | digest)) 89 | return HttpResponse('') 90 | 91 | self.links = None 92 | if 'HTTP_LINK' in request.META: 93 | self.links = parse_header_links(request.META['HTTP_LINK']) 94 | updated.send(sender=subscription, notification=request.body, 95 | request=request, links=self.links) 96 | self.subscription = subscription 97 | self.handle_subscription() 98 | return HttpResponse('') 99 | 100 | def handle_subscription(self): 101 | """Subclasses may implement this""" 102 | pass 103 | 104 | 105 | callback = CallbackView.as_view() 106 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-push.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-push.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # django-push documentation build configuration file, created by 3 | # sphinx-quickstart on Sun Jul 4 14:18:51 2010. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import datetime 14 | 15 | import sphinx_rtd_theme 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | #sys.path.append(os.path.abspath('.')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # Add any Sphinx extension module names here, as strings. They can be extensions 25 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 26 | extensions = [] 27 | 28 | # Add any paths that contain templates here, relative to this directory. 29 | templates_path = ['_templates'] 30 | 31 | # The suffix of source filenames. 32 | source_suffix = '.rst' 33 | 34 | # The encoding of source files. 35 | #source_encoding = 'utf-8' 36 | 37 | # The master toctree document. 38 | master_doc = 'index' 39 | 40 | # General information about the project. 41 | project = 'django-push' 42 | copyright = '2010-{0}, Bruno Renié'.format(datetime.datetime.today().year) 43 | 44 | # The version info for the project you're documenting, acts as replacement for 45 | # |version| and |release|, also used in various other places throughout the 46 | # built documents. 47 | # 48 | # The short X.Y version. 49 | version = '2.0' 50 | # The full version, including alpha/beta/rc tags. 51 | release = '2.0' 52 | 53 | # The language for content autogenerated by Sphinx. Refer to documentation 54 | # for a list of supported languages. 55 | #language = None 56 | 57 | # There are two options for replacing |today|: either, you set today to some 58 | # non-false value, then it is used: 59 | #today = '' 60 | # Else, today_fmt is used as the format for a strftime call. 61 | #today_fmt = '%B %d, %Y' 62 | 63 | # List of documents that shouldn't be included in the build. 64 | #unused_docs = [] 65 | 66 | # List of directories, relative to source directory, that shouldn't be searched 67 | # for source files. 68 | exclude_trees = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. Major themes that come with 94 | # Sphinx are currently 'default' and 'sphinxdoc'. 95 | html_theme = 'sphinx_rtd_theme' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | #html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_use_modindex = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, an OpenSearch description file will be output, and all pages will 154 | # contain a tag referring to it. The value of this option must be the 155 | # base URL from which the finished HTML is served. 156 | #html_use_opensearch = '' 157 | 158 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 159 | #html_file_suffix = '' 160 | 161 | # Output file base name for HTML help builder. 162 | htmlhelp_basename = 'django-pushdoc' 163 | 164 | 165 | # -- Options for LaTeX output -------------------------------------------------- 166 | 167 | # The paper size ('letter' or 'a4'). 168 | #latex_paper_size = 'letter' 169 | 170 | # The font size ('10pt', '11pt' or '12pt'). 171 | #latex_font_size = '10pt' 172 | 173 | # Grouping the document tree into LaTeX files. List of tuples 174 | # (source start file, target name, title, author, documentclass [howto/manual]). 175 | latex_documents = [ 176 | ('index', 'django-push.tex', 'django-push Documentation', 177 | 'Bruno Renié', 'manual'), 178 | ] 179 | 180 | # The name of an image file (relative to this directory) to place at the top of 181 | # the title page. 182 | #latex_logo = None 183 | 184 | # For "manual" documents, if this is true, then toplevel headings are parts, 185 | # not chapters. 186 | #latex_use_parts = False 187 | 188 | # Additional stuff for the LaTeX preamble. 189 | #latex_preamble = '' 190 | 191 | # Documents to append as an appendix to all manuals. 192 | #latex_appendices = [] 193 | 194 | # If false, no module index is generated. 195 | #latex_use_modindex = True 196 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django-PuSH 2 | =========== 3 | 4 | PuSH is the other name of `PubSubHubbub`_, a publish/subscribe protocol based 5 | on HTTP and allowing near-instant notifications of topic updates. 6 | 7 | * Publishers are entities that publish their updates via HTTP resources. When a 8 | resource is updated with a new entry, they ping their *hub* saying they have 9 | some new content. The hub is also declared in the resource. 10 | 11 | * Subscribers are feed readers or followers. When they fetch a resource, they 12 | notice a hub is declared and subscribe to the resource's updates with the 13 | hub. 14 | 15 | * Hubs fetch the published resource when it gets a ping from the publisher and 16 | takes care of notifying all the subscribers. 17 | 18 | .. _PubSubHubbub: http://code.google.com/p/pubsubhubbub/ 19 | 20 | This library provides hooks to add PubSubHubbub support to your Django 21 | project: you can use it to be a publisher and/or subscriber. 22 | 23 | The PubSubHubbub spec was initially designed for Atom feeds. The `0.3 24 | version`_ of the spec defines resources as feeds. The `0.4`_ version allows 25 | arbitrary content types. The `0.4`_ spec is supported since version **0.5** of 26 | django-push. We unfortunately missed the chance of having version numbers 27 | match properly. 28 | 29 | .. _0.3 version: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html 30 | 31 | .. _0.4: http://superfeedr-misc.s3.amazonaws.com/pubsubhubbub-core-0.4.html 32 | 33 | Installation 34 | ------------ 35 | 36 | .. code-block:: bash 37 | 38 | pip install django-push 39 | 40 | Manual 41 | ------ 42 | 43 | .. toctree:: 44 | :maxdepth: 2 45 | 46 | publisher 47 | subscriber 48 | 49 | Changelog 50 | --------- 51 | 52 | * **2.0** (2023-12-01) 53 | 54 | * Remove support for Django < 3.2. 55 | 56 | * Add support for Django 3.2, 4.1, 4.2 and 5.0. 57 | 58 | * Add support for Python 3.8 to 3.11 59 | 60 | * Drop support for Python < 3.8. 61 | 62 | * **1.1** (2018-06-06) 63 | 64 | * Remove support for Django < 1.11. 65 | 66 | * Add support for Django 2.0 and 2.1. 67 | 68 | * **1.0** (2017-04-25): 69 | 70 | * Confirm support for Django 1.11 (no code changes required). 71 | 72 | * **0.9** (2016-07-13): 73 | 74 | * Remove support for Django 1.7. 75 | 76 | * Drop support for Python 3.2. 77 | 78 | * Confirm support for Django 1.10. 79 | 80 | * **0.8** (2015-09-29): 81 | 82 | * Remove support for Django < 1.7. 83 | 84 | * Use a transaction hook in ``Subscription.objects.subscribe()`` when 85 | available (Django 1.9+). 86 | 87 | * **0.7** (2015-07-10): 88 | 89 | * Remove warnings with Django versions up to 1.8. 90 | 91 | * **0.6.1** (2014-01-14): 92 | 93 | * Added ``PUSH_TIMEOUT`` setting for passing timeouts to the 94 | subscribe/unsubscribe HTTP calls. 95 | 96 | * **0.6** (2013-07-10): 97 | 98 | * Removed ``get_hub()``. 99 | 100 | * Removed the ``unsubscribe()`` manager method. Unsubscribing must be done 101 | with subscription instances. 102 | 103 | * Added ``request`` and ``links`` keyword arguments to the ``updated`` 104 | signal. ``request`` is the raw HTTP request object, ``links`` is a parsed 105 | version of the ``Link`` header, if present. 106 | 107 | * **0.5** (2013-06-24): 108 | 109 | * Python 3 support, Django >= 1.4.1 support. 110 | 111 | * HTTP handling via requests instead of urllib2. 112 | 113 |  * Deprecation of ``Subscription.objects.unsubscribe()`` in favor of an 114 | instance method on the subscription object. The ``unsubscribe()`` manager 115 | method will be removed in version 0.6. 116 | 117 | * ``Subscription.objects.subscribe()`` raises a warning if the ``hub`` kwarg 118 | is not provided. It will become mandatory in version 0.6. 119 | 120 | * Removed ``hub.verify_token`` from subscription requests. It's optional in 121 | the 0.3 spec and absent from the 0.4 spec. 122 | 123 | * Secret generation code uses ``django.utils.crypto`` instead of the 124 | ``random`` module. In addition, subscriptions over HTTP don't use a secret 125 | anymore (as recommended in the spec). 126 | 127 | * The ``updated`` signal is sent with the raw payload instead of the result 128 | of a ``feedparser.parse`` call. This allows other content types than feeds 129 | to be processed, as suggested in version 0.4 of the PubSubHubbub spec. 130 | 131 | * The callback view is now a class-based view, allowing listening for content 132 | distribution via a custom view if the ``updated`` signal is not suitable. 133 | 134 | * ``django.contrib.sites`` is no longer a hard requirement. You can set 135 | ``PUSH_DOMAIN`` in your settings to your site's canonical hostname. 136 | 137 | * South migrations support. If you don't use South, you should. If you're 138 | upgrading from 0.4, just **fake the first migration** and apply the 139 | others:: 140 | 141 | ./manage.py migrate subscriber 0001_initial --fake 142 | ./manage.py migrate 143 | 144 | * Tremendously improved admin support. If you were using a custom ModelAdmin 145 | for subscriptions, you might want to try the built-in one. 146 | 147 | * **0.4** (2011-06-30): 148 | 149 | * Support for hub authentication via ``PUSH_HUB_CREDENTIALS``. 150 | 151 | * Support for SSL callback URLs. 152 | 153 | * **0.3** (2010-08-18): 154 | 155 | * Subscribers can unsubscribe. 156 | 157 | * **0.2** (2010-08-12): 158 | 159 | * Signature handling of content distribution requests. 160 | 161 | * **0.1** (2010-08-11): 162 | 163 | * Initial release. 164 | -------------------------------------------------------------------------------- /docs/publisher.rst: -------------------------------------------------------------------------------- 1 | Being a publisher 2 | ================= 3 | 4 | Declare your hub 5 | ---------------- 6 | 7 | First, you need a hub. You can either use your own or use a `public hub`_. 8 | See the hub's documentation for adding a new feed and add your hub's URL as 9 | a ``PUSH_HUB`` setting (the URL **must** be a full URL): 10 | 11 | .. _public hub: https://pubsubhubbub.appspot.com 12 | 13 | .. code-block:: python 14 | 15 | PUSH_HUB = 'https://pubsubhubbub.appspot.com' 16 | 17 | Finally, use *django-push*'s base feed to declare your feeds. Instead of 18 | importing ``django.contrib.syndication.views.Feed``, do it this way: 19 | 20 | .. code-block:: python 21 | 22 | from django_push.publisher.feeds import Feed 23 | 24 | class MyFeed(Feed): 25 | title = 'My Feed' 26 | link = '...' 27 | 28 | def items(self): 29 | return MyModel.objects.filter(...) 30 | 31 | Django-push will take care of adding the hub declaration to the feeds. By 32 | default, the hub is set to your ``PUSH_HUB`` setting. If you want to change 33 | it, see :ref:`different-hubs`. 34 | 35 | Django-push's feed is just a slightly modified version of the ``Feed`` class 36 | from the ``contrib.syndication`` app, however its type is forced to be an 37 | Atom feed. While some hubs may be compatible with RSS and Atom feeds, the 38 | PubSubHubbub specifications encourages the use of Atom feeds. Make sure you 39 | use the Atom attributes, like ``subtitle`` instead of ``description`` for 40 | instance. If you're already publishing Atom feeds, you're fine. 41 | 42 | .. _different-hubs: 43 | 44 | Use different hubs for each feed 45 | ```````````````````````````````` 46 | 47 | If you want to use different hubs for different feeds, just set the ``hub`` 48 | attribute to the URL you want: 49 | 50 | .. code-block:: python 51 | 52 | from django_push.publisher.feeds import Feed 53 | 54 | class MyFeed(Feed): 55 | title = 'My Feed' 56 | link = '...' 57 | hub = 'http://hub.example.com' 58 | 59 | class MyOtherFeed(Feed): 60 | hub = 'http://some-other-hub.com' 61 | 62 | By default, the ``Feed`` class will use the ``PUSH_HUB`` setting. 63 | 64 | If you need to compute the hub URL at runtime, override the ``get_hub`` 65 | method on your feed subclass: 66 | 67 | .. code-block:: python 68 | 69 | from django_push.publisher.feeds import Feed 70 | 71 | class MyFeed(Feed): 72 | def get_hub(self, obj): 73 | return some_dynamic_url 74 | 75 | The ``get_hub`` method was added in django-push 0.5. 76 | 77 | Ping the hub on feed updates 78 | ---------------------------- 79 | 80 | Once your feeds are configured, you need to ping the hub each time a new 81 | item/entry is published. Since you may have your own publishing mechanics, you 82 | need to call a ``ping_hub`` function when a new entry is made available. For 83 | example, if a model has a ``publish()`` method: 84 | 85 | .. code-block:: python 86 | 87 | from django.contrib.sites.models import get_current_site 88 | from django.core.urlresolvers import reverse 89 | from django.db import models 90 | from django.utils import timezone 91 | 92 | from django_push.publisher import ping_hub 93 | 94 | class MyModel(models.Model): 95 | def publish(self): 96 | self.published = True 97 | self.timestamp = timezone.now() 98 | self.save() 99 | 100 | ping_hub('http://%s%s' % (get_current_site().domain, 101 | reverse('feed_for_mymodel'))) 102 | 103 | ``ping_hub`` has to be called with the full URL of the Atom feed as parameter, 104 | using either the Sites framework or your own mechanism to add the domain 105 | name. By default, ``ping_hub`` will ping the hub declared in the ``PUSH_HUB`` 106 | setting. A different hub can be set using an optional ``hub_url`` keyword 107 | argument: 108 | 109 | .. code-block:: python 110 | 111 | from django_push.publisher import ping_hub 112 | 113 | ping_hub('http://example.com/feed.atom', 114 | hub_url='http://hub.example.com') 115 | -------------------------------------------------------------------------------- /docs/subscriber.rst: -------------------------------------------------------------------------------- 1 | Being a subscriber 2 | ================== 3 | 4 | * Add ``django_push.subscriber`` to your ``INSTALLED_APPS`` and 5 | run ``manage.py migrate``. 6 | 7 | * Include ``django_push.subscriber.urls`` in your main urlconf: 8 | 9 | .. code-block:: python 10 | 11 | urlpatterns = [ 12 | # ... 13 | url(r'^subscriber/', include('django_push.subscriber.urls')), 14 | ] 15 | 16 | * If you have ``django.contrib.sites`` installed, make sure it is correctly 17 | configured: check that ``Site.objects.get_current()`` actually returns the 18 | domain of your publicly accessible website. 19 | 20 | * If you don't use ``django.contrib.sites``, set ``PUSH_DOMAIN`` to your 21 | site's domain in your settings. 22 | 23 | * Additionally if your site is available via HTTPS, set ``PUSH_SSL_CALLBACK`` 24 | to ``True``. 25 | 26 | Initial subscription 27 | -------------------- 28 | 29 | Let's assume you're already parsing feeds. Your code may look like this: 30 | 31 | .. code-block:: python 32 | 33 | import feedparser 34 | 35 | 36 | parsed = feedparser.parse('http://example.com/feed/') 37 | for entry in parsed.entries: 38 | # Do something with the entries: store them, email them... 39 | do_something() 40 | 41 | You need to modify this code to check if the feed declares a hub and initiate 42 | a subscription for this feed. 43 | 44 | .. code-block:: python 45 | 46 | parsed = feedparser.parse('http://example.com/feed/') 47 | 48 | if 'links' in parsed.feed: 49 | for link in parsed.feed.links: 50 | if link.rel == 'hub': 51 | # Hub detected! 52 | hub = link.href 53 | 54 | Now that you found a hub, you can create a subscription: 55 | 56 | .. code-block:: python 57 | 58 | from django_push.subscriber.models import Subscription 59 | 60 | 61 | subscription = Subscription.objects.subscribe(feed_url, hub=hub, 62 | lease_seconds=12345) 63 | 64 | If a subscription for this feed already exists, no new subscription is 65 | created but the existing subscription is renewed. 66 | 67 | ``lease_seconds`` is optional and **only a hint** for the hub. If the hub has 68 | a custom expiration policy it may chose another value arbitrarily. The value 69 | chose by the hub is saved in the subscription object when the subscription 70 | gets verified. 71 | 72 | If you want to set a default ``lease_seconds``, you can use the 73 | ``PUSH_LEASE_SECONDS`` setting. 74 | 75 | If there's a danger of hub freezing the connection (it happens in the wild) 76 | you can use the ``PUSH_TIMEOUT`` setting. Its value should be the number 77 | of seconds (float) to wait for the subscription request to finish. Good number 78 | might be 60. 79 | 80 | Renewing the leases 81 | ------------------- 82 | 83 | As we can see, the hub subscription can be valid for a certain amount of time. 84 | 85 | Version 0.3 of the PubSubHubbub spec explains that hub must recheck with 86 | subscribers before subscriptions expire to automatically renew subscriptions. 87 | This is not the case in version 0.4 of the spec. 88 | 89 | In any case you can renew the leases before the expire to make sure they are 90 | not forgotten by the hub. For instance, this could be run once a day: 91 | 92 | .. code-block:: python 93 | 94 | import datetime 95 | 96 | from django.utils import timezone 97 | 98 | from django_push.subscriber.models import Subscription 99 | 100 | 101 | tomorrow = timezone.now() + datetime.timedelta(days=1) 102 | 103 | for subscription in Subscription.objects.filter( 104 | verified=True, 105 | lease_expiration__lte=tomorrow 106 | ): 107 | subscription.subscribe() 108 | 109 | Unsubscribing 110 | ------------- 111 | 112 | If you want to stop receiving notification for a feed's updates, you need to 113 | unsubscribe. This is as simple as doing: 114 | 115 | .. code-block:: python 116 | 117 | from django_push.subscriber.models import Subscription 118 | 119 | subscription = Subscription.objects.get(topic='http://example.com/feed') 120 | subscription.unsubscribe() 121 | 122 | The hub is notified to cancel the subscription and the Subscription object is 123 | deleted. You can also specify the hub if a topic uses several hubs: 124 | 125 | .. code-block:: python 126 | 127 | subscription = Subscription.objects.get(topic=feed_url, hub=hub_url) 128 | subscription.unsubscribe() 129 | 130 | Authentication 131 | -------------- 132 | 133 | Some hubs may require basic auth for subscription requests. Django-PuSH 134 | provides a way to supply authentication information via a callable that takes 135 | the hub URL as a parameter and returns None (no authentication required) or a 136 | (username, password) tuple. For instance: 137 | 138 | .. code-block:: python 139 | 140 | def custom_hub_credentials(hub_url): 141 | if hub_url == 'http://superfeedr.com/hubbub': 142 | return ('my_superfeedr_username', 'password') 143 | 144 | And then, set the ``PUSH_CREDENTIALS`` setting to the dotted path to your 145 | custom function: 146 | 147 | .. code-block:: python 148 | 149 | PUSH_CREDENTIALS = 'path.to.custom_hub_credentials' 150 | 151 | This way you have full control of the way credentials are stored (database, 152 | settings, filesystem…) 153 | 154 | Using HTTPS Callback URLs 155 | ------------------------- 156 | 157 | By default, callback URLs will be constructed using HTTP. If you would like 158 | to use HTTPS for callback URLs, set the ``PUSH_SSL_CALLBACK`` setting to True: 159 | 160 | .. code-block:: python 161 | 162 | PUSH_SSL_CALLBACK = True 163 | 164 | Listening to Hubs' notifications 165 | -------------------------------- 166 | 167 | Once subscriptions are setup, the hubs will start to send notifications to 168 | your callback URLs. Each time a notification is received, the 169 | ``django_push.subscriber.signals.updated`` signal is sent. You can define a 170 | receiver function: 171 | 172 | .. code-block:: python 173 | 174 | import feedparser 175 | 176 | from django_push.subscriber.signals import updated 177 | 178 | def listener(notification, **kwargs): 179 | parsed = feedparser.parse(notification) 180 | for entry in parsed.entries: 181 | print entry.title, entry.link 182 | 183 | updated.connect(listener) 184 | 185 | The ``notification`` parameter is the raw payload from the hub. If you expect 186 | an RSS/Atom feed you should process the payload using a library such as the 187 | `universal feedparser`_. 188 | 189 | ``kwargs`` also contains the raw HTTP request object and the parsed ``Link`` 190 | header if it is present. You can take advantage of them to validate the 191 | notification: 192 | 193 | .. code-block:: python 194 | 195 | def listener(notification, request, links, **kwargs): 196 | if links is not None: 197 | for link in links: 198 | if link['rel'] == 'self': 199 | break 200 | url = link['url'] # This is the topic URL 201 | 202 | .. _universal feedparser: http://pythonhosted.org/feedparser/ 203 | 204 | Listening with a view instead of the ``updated`` signal 205 | ------------------------------------------------------- 206 | 207 | If Django signals are not your thing, you can inherit from the base subscriber 208 | view to listen for notifications: 209 | 210 | .. code-block:: python 211 | 212 | from django_push.subscriber.views import CallbackView 213 | 214 | class MyCallback(CallbackView): 215 | def handle_subscription(self): 216 | payload = self.request.body 217 | parsed = feedparser.parse(payload) 218 | for entry in payload.entries: 219 | do_stuff_with(entry) 220 | callback = MyCallback.as_view() 221 | 222 | Then instead of including ``django_push.subscriber.urls`` in your urlconf, 223 | define a custom URL with ``subscriber_callback`` as a name and a ``pk`` named 224 | parameter: 225 | 226 | .. code-block:: python 227 | 228 | from django.conf.urls import patterns, url 229 | 230 | from .views import callback 231 | 232 | urlpatterns = patterns( 233 | '', 234 | url(r'^subscriber/(?P\d+)/$', callback, name='subscriber_callback'), 235 | ) 236 | 237 | In the ``handle_subscription`` method of the view, you can access 238 | ``self.request``, ``self.subscription`` and ``self.links``. 239 | 240 | Logging 241 | ------- 242 | 243 | You can listen for log messages by configuring the ``django_push`` logger: 244 | 245 | .. code-block:: python 246 | 247 | LOGGING = { 248 | 'handlers': { 249 | 'console': { 250 | 'level': 'DEBUG', 251 | 'class': 'logging.StreamHandler', 252 | }, 253 | }, 254 | 'loggers': { 255 | 'django_push': { 256 | 'handlers': ['console'], 257 | 'level': 'DEBUG', 258 | }, 259 | }, 260 | } 261 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import warnings 4 | 5 | import django 6 | from django.test.runner import DiscoverRunner 7 | 8 | warnings.simplefilter('always') 9 | 10 | 11 | def runtests(): 12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 13 | 14 | parent = os.path.dirname(os.path.abspath(__file__)) 15 | sys.path.insert(0, parent) 16 | 17 | django.setup() 18 | 19 | runner = DiscoverRunner(verbosity=1, interactive=True, 20 | failfast=False) 21 | failures = runner.run_tests(()) 22 | sys.exit(failures) 23 | 24 | 25 | if __name__ == '__main__': 26 | runtests() 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | exclude = migrations 6 | 7 | [coverage:html] 8 | skip_covered = true 9 | skip_empty = true 10 | 11 | [coverage:run] 12 | branch = 1 13 | source = django_push 14 | 15 | [coverage:report] 16 | fail_under = 77 17 | omit = *migrations* 18 | show_missing = true 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from setuptools import find_packages 3 | 4 | 5 | with open('README.rst') as readme: 6 | long_description = readme.read() 7 | 8 | setup( 9 | name='django-push', 10 | version=__import__('django_push').__version__, 11 | author='Bruno Renié', 12 | author_email='bruno@renie.fr', 13 | url='https://github.com/brutasse/django-push', 14 | license='BSD', 15 | description='PubSubHubbub (PuSH) support for Django', 16 | long_description=long_description, 17 | packages=find_packages(), 18 | include_package_data=True, 19 | install_requires=[ 20 | 'Django', 21 | 'requests', 22 | ], 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Environment :: Web Environment', 26 | 'Framework :: Django', 27 | 'Framework :: Django :: 3.2', 28 | 'Framework :: Django :: 4.1', 29 | 'Framework :: Django :: 4.2', 30 | 'Framework :: Django :: 5.0', 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: BSD License', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.8', 36 | 'Programming Language :: Python :: 3.9', 37 | 'Programming Language :: Python :: 3.10', 38 | 'Programming Language :: Python :: 3.11', 39 | 'Programming Language :: Python :: 3 :: Only', 40 | ], 41 | test_suite='runtests.runtests', 42 | zip_safe=False, 43 | ) 44 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO as BaseBytesIO 2 | 3 | from requests import Response 4 | 5 | 6 | class BytesIO(BaseBytesIO): 7 | def read(self, *args, **kwargs): 8 | kwargs.pop('decode_content', None) 9 | return super().read(*args, **kwargs) 10 | 11 | 12 | def response(status_code=200, content='', headers={}): 13 | response = Response() 14 | response.status_code = status_code 15 | response.raw = BytesIO(content.encode('utf-8')) 16 | response.headers = headers 17 | return response 18 | -------------------------------------------------------------------------------- /tests/publisher/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/django-push/22fda99641cfbd2f3075a723d92652a8e38220a5/tests/publisher/__init__.py -------------------------------------------------------------------------------- /tests/publisher/feeds.py: -------------------------------------------------------------------------------- 1 | from django_push.publisher.feeds import Feed 2 | 3 | 4 | class HubFeed(Feed): 5 | link = '/feed/' 6 | 7 | def items(self): 8 | return [1, 2, 3] 9 | 10 | def item_title(self, item): 11 | return str(item) 12 | 13 | def item_link(self, item): 14 | return '/items/{0}'.format(item) 15 | 16 | 17 | class OverrideHubFeed(HubFeed): 18 | link = '/overriden-feed/' 19 | hub = 'http://example.com/overridden-hub' 20 | 21 | 22 | class DynamicHubFeed(HubFeed): 23 | link = '/dynamic-feed/' 24 | 25 | def get_hub(self, obj): 26 | return 'http://dynamic-hub.example.com/' 27 | -------------------------------------------------------------------------------- /tests/publisher/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/django-push/22fda99641cfbd2f3075a723d92652a8e38220a5/tests/publisher/models.py -------------------------------------------------------------------------------- /tests/publisher/tests.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.urls import reverse 4 | from django.test import TestCase 5 | from django.test.utils import override_settings 6 | 7 | from django_push import UA 8 | from django_push.publisher import ping_hub 9 | 10 | 11 | class PubTestCase(TestCase): 12 | @mock.patch('requests.post') 13 | def test_explicit_ping(self, post): 14 | post.return_value = 'Response' 15 | with self.assertRaises(ValueError): 16 | ping_hub('http://example.com/feed') 17 | 18 | ping_hub('http://example.com/feed', hub_url='http://example.com/hub') 19 | post.assert_called_once_with( 20 | 'http://example.com/hub', 21 | headers={'User-Agent': UA}, 22 | data={'hub.url': 'http://example.com/feed', 23 | 'hub.mode': 'publish'}) 24 | 25 | @mock.patch('requests.post') 26 | @override_settings(PUSH_HUB='http://hub.example.com') 27 | def test_ping_settings(self, post): 28 | post.return_value = 'Response' 29 | ping_hub('http://example.com/feed') 30 | post.assert_called_once_with( 31 | 'http://hub.example.com', 32 | headers={'User-Agent': UA}, 33 | data={'hub.url': 'http://example.com/feed', 34 | 'hub.mode': 'publish'}) 35 | 36 | @mock.patch('requests.post') 37 | @override_settings(PUSH_HUB='http://hub.example.com') 38 | def test_ping_settings_override(self, post): 39 | post.return_value = 'Response' 40 | ping_hub('http://example.com/feed', hub_url='http://google.com') 41 | post.assert_called_once_with( 42 | 'http://google.com', 43 | headers={'User-Agent': UA}, 44 | data={'hub.url': 'http://example.com/feed', 45 | 'hub.mode': 'publish'}) 46 | 47 | @override_settings(PUSH_HUB='http://hub.example.com') 48 | def test_hub_declaration(self): 49 | response = self.client.get(reverse('feed')) 50 | hub_declaration = response.content.decode('utf-8').split( 51 | '', 1)[1].split('', 1)[0] 52 | self.assertTrue('rel="hub"' in hub_declaration) 53 | self.assertTrue('href="http://hub.example.com' in hub_declaration) 54 | 55 | response = self.client.get(reverse('override-feed')) 56 | hub_declaration = response.content.decode('utf-8').split( 57 | '', 1)[1].split('', 1)[0] 58 | self.assertTrue('rel="hub"' in hub_declaration) 59 | self.assertFalse('href="http://hub.example.com' in hub_declaration) 60 | self.assertTrue( 61 | 'href="http://example.com/overridden-hub' in hub_declaration 62 | ) 63 | 64 | response = self.client.get(reverse('dynamic-feed')) 65 | hub_declaration = response.content.decode('utf-8').split( 66 | '', 1)[1].split('', 1)[0] 67 | self.assertTrue('rel="hub"' in hub_declaration) 68 | self.assertFalse('href="http://hub.example.com' in hub_declaration) 69 | self.assertTrue( 70 | 'href="http://dynamic-hub.example.com/' in hub_declaration 71 | ) 72 | 73 | def test_no_hub(self): 74 | response = self.client.get(reverse('feed')) 75 | self.assertEqual(response.status_code, 200) 76 | -------------------------------------------------------------------------------- /tests/publisher/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from .feeds import HubFeed, OverrideHubFeed, DynamicHubFeed 4 | 5 | 6 | urlpatterns = [ 7 | path('feed/', HubFeed(), name='feed'), 8 | path('override-feed/', OverrideHubFeed(), name='override-feed'), 9 | path('dynamic-feed/', DynamicHubFeed(), name='dynamic-feed'), 10 | path('subscriber/', include('django_push.subscriber.urls')), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 4 | DATABASES = { 5 | 'default': { 6 | 'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': os.path.join(BASE_DIR, 'push.sqlite'), 8 | }, 9 | } 10 | 11 | INSTALLED_APPS = ( 12 | 'django.contrib.sites', 13 | 'django_push.subscriber', 14 | 'tests.publisher', 15 | 'tests.subscribe', 16 | ) 17 | 18 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 19 | 20 | STATIC_URL = '/static/' 21 | 22 | SECRET_KEY = 'test secret key' 23 | 24 | ROOT_URLCONF = 'tests.publisher.urls' 25 | 26 | SITE_ID = 1 27 | 28 | PUSH_DOMAIN = 'testserver.com' 29 | 30 | MIDDLEWARE = () 31 | -------------------------------------------------------------------------------- /tests/subscribe/__init__.py: -------------------------------------------------------------------------------- 1 | def credentials(hub_url): 2 | return ('username', 'password') 3 | -------------------------------------------------------------------------------- /tests/subscribe/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/django-push/22fda99641cfbd2f3075a723d92652a8e38220a5/tests/subscribe/models.py -------------------------------------------------------------------------------- /tests/subscribe/tests.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.test import TransactionTestCase 6 | from django.test.utils import override_settings 7 | from django.urls import reverse 8 | from django.utils import timezone 9 | 10 | from django_push.subscriber.models import Subscription, SubscriptionError 11 | from django_push.subscriber.utils import get_domain 12 | from django_push.subscriber.signals import updated 13 | 14 | from .. import response 15 | 16 | 17 | class SubscriberTestCase(TransactionTestCase): 18 | def setUp(self): 19 | self.signals = [] 20 | updated.connect(self._signal_handler) 21 | 22 | def _signal_handler(self, sender, notification, **kwargs): 23 | self.signals.append([sender, notification, kwargs]) 24 | 25 | @override_settings(INSTALLED_APPS=[]) 26 | @mock.patch('requests.post') 27 | def test_subscribing(self, post): 28 | post.return_value = response(status_code=202) 29 | s = Subscription.objects.subscribe("http://example.com/feed", 30 | "http://hub.domain.com/hub") 31 | url = reverse('subscriber_callback', args=[s.pk]) 32 | post.assert_called_once_with( 33 | 'http://hub.domain.com/hub', 34 | data={ 35 | 'hub.callback': 'http://testserver.com{0}'.format(url), 36 | 'hub.verify': ['sync', 'async'], 37 | 'hub.topic': 'http://example.com/feed', 38 | 'hub.mode': 'subscribe', 39 | }, 40 | auth=None, 41 | timeout=None, 42 | ) 43 | 44 | s = Subscription.objects.get(pk=s.pk) 45 | self.assertIs(s.verified, False) 46 | self.assertIs(s.lease_expiration, None) 47 | 48 | @mock.patch('requests.get') 49 | @mock.patch('requests.post') 50 | def test_subscribe_no_hub_warning(self, post, get): 51 | post.return_value = response(status_code=202) 52 | get.return_value = response(status_code=200, content="""http://testserver/overriden-feed/2013-06-23T10:58:30Z""") # noqa 53 | 54 | @mock.patch('requests.post') 55 | def test_subscription_secret(self, post): 56 | post.return_value = response(status_code=202) 57 | s = Subscription.objects.subscribe( 58 | 'http://foo.com/insecure', hub='http://insecure.example.com/hub') 59 | self.assertEqual(s.secret, '') 60 | s = Subscription.objects.subscribe( 61 | 'http://foo.com/secure', hub='https://secure.example.com/hub') 62 | self.assertEqual(len(s.secret), 50) 63 | 64 | @mock.patch('requests.post') 65 | def test_sync_subscribing(self, post): 66 | post.return_value = response(status_code=204) 67 | Subscription.objects.subscribe("http://example.com/feed", 68 | "http://hub.domain.com/hub") 69 | self.assertEqual(len(post.call_args_list), 1) 70 | subscription = Subscription.objects.get() 71 | self.assertEqual(subscription.verified, True) 72 | 73 | def test_get_domain(self): 74 | self.assertEqual(get_domain(), 'testserver.com') 75 | push_domain = settings.PUSH_DOMAIN 76 | del settings.PUSH_DOMAIN 77 | self.assertEqual(get_domain(), 'example.com') 78 | 79 | with self.settings(INSTALLED_APPS=[]): 80 | with self.assertRaises(ImproperlyConfigured): 81 | get_domain() 82 | 83 | settings.PUSH_DOMAIN = push_domain 84 | 85 | @mock.patch('requests.post') 86 | def test_manager_unsubscribe(self, post): 87 | post.return_value = response(status_code=202) 88 | s = Subscription.objects.create(topic='http://example.com/feed', 89 | hub='http://hub.example.com') 90 | post.assert_not_called() 91 | s.unsubscribe() 92 | post.assert_called_once_with( 93 | 'http://hub.example.com', 94 | data={ 95 | 'hub.callback': s.callback_url, 96 | 'hub.verify': ['sync', 'async'], 97 | 'hub.topic': 'http://example.com/feed', 98 | 'hub.mode': 'unsubscribe', 99 | }, 100 | auth=None, 101 | timeout=None, 102 | ) 103 | 104 | @mock.patch('requests.post') 105 | def test_subscribe_lease_seconds(self, post): 106 | post.return_value = response(status_code=202) 107 | with self.settings(PUSH_LEASE_SECONDS=14): # overriden in the call 108 | s = Subscription.objects.subscribe('http://test.example.com/feed', 109 | hub='http://hub.example.com', 110 | lease_seconds=12) 111 | post.assert_called_once_with( 112 | 'http://hub.example.com', 113 | data={ 114 | 'hub.callback': s.callback_url, 115 | 'hub.verify': ['sync', 'async'], 116 | 'hub.topic': 'http://test.example.com/feed', 117 | 'hub.mode': 'subscribe', 118 | 'hub.lease_seconds': 12, 119 | }, 120 | auth=None, 121 | timeout=None, 122 | ) 123 | 124 | @mock.patch('requests.post') 125 | def test_subscribe_timeout(self, post): 126 | post.return_value = response(status_code=202) 127 | with self.settings(PUSH_TIMEOUT=10): # overriden in the call 128 | s = Subscription.objects.subscribe('http://test.example.com/feed', 129 | hub='http://hub.example.com', 130 | ) 131 | post.assert_called_once_with( 132 | 'http://hub.example.com', 133 | data={ 134 | 'hub.callback': s.callback_url, 135 | 'hub.verify': ['sync', 'async'], 136 | 'hub.topic': 'http://test.example.com/feed', 137 | 'hub.mode': 'subscribe', 138 | }, 139 | auth=None, 140 | timeout=10, 141 | ) 142 | 143 | @mock.patch('requests.post') 144 | def test_lease_seconds_from_settings(self, post): 145 | post.return_value = response(status_code=202) 146 | with self.settings(PUSH_LEASE_SECONDS=2592000): 147 | s = Subscription.objects.subscribe('http://test.example.com/feed', 148 | hub='http://hub.example.com') 149 | post.assert_called_once_with( 150 | 'http://hub.example.com', 151 | data={ 152 | 'hub.callback': s.callback_url, 153 | 'hub.verify': ['sync', 'async'], 154 | 'hub.topic': 'http://test.example.com/feed', 155 | 'hub.mode': 'subscribe', 156 | 'hub.lease_seconds': 2592000, 157 | }, 158 | auth=None, 159 | timeout=None, 160 | ) 161 | 162 | @mock.patch('requests.post') 163 | def test_subscription_error(self, post): 164 | post.return_value = response(status_code=200) 165 | with self.assertRaises(SubscriptionError): 166 | Subscription.objects.subscribe('http://example.com/test', 167 | hub='http://hub.example.com') 168 | 169 | @override_settings(PUSH_CREDENTIALS='tests.subscribe.credentials') 170 | @mock.patch('requests.post') 171 | def test_hub_credentials(self, post): 172 | post.return_value = response(status_code=202) 173 | s = Subscription.objects.subscribe('http://example.com/test', 174 | hub='http://hub.example.com') 175 | post.assert_called_once_with( 176 | 'http://hub.example.com', 177 | data={ 178 | 'hub.callback': s.callback_url, 179 | 'hub.verify': ['sync', 'async'], 180 | 'hub.topic': 'http://example.com/test', 181 | 'hub.mode': 'subscribe', 182 | }, 183 | auth=('username', 'password'), 184 | timeout=None, 185 | ) 186 | 187 | def test_missing_callback_params(self): 188 | s = Subscription.objects.create(topic='foo', hub='bar') 189 | url = reverse('subscriber_callback', args=[s.pk]) 190 | response = self.client.get(url) 191 | self.assertContains( 192 | response, 193 | "Missing parameters: hub.mode, hub.topic, hub.challenge", 194 | status_code=400, 195 | ) 196 | 197 | def test_wrong_topic(self): 198 | s = Subscription.objects.create(topic='foo', hub='bar') 199 | url = reverse('subscriber_callback', args=[s.pk]) 200 | response = self.client.get(url, { 201 | 'hub.topic': 'baz', 202 | 'hub.mode': 'subscribe', 203 | 'hub.challenge': 'challenge yo', 204 | }) 205 | self.assertContains(response, 'Mismatching topic URL', status_code=400) 206 | 207 | def test_wrong_mode(self): 208 | s = Subscription.objects.create(topic='foo', hub='bar') 209 | url = reverse('subscriber_callback', args=[s.pk]) 210 | response = self.client.get(url, { 211 | 'hub.topic': 'foo', 212 | 'hub.mode': 'modemode', 213 | 'hub.challenge': 'challenge yo', 214 | }) 215 | self.assertContains(response, 'Unrecognized hub.mode parameter', 216 | status_code=400) 217 | 218 | def test_missing_lease_seconds(self): 219 | s = Subscription.objects.create(topic='foo', hub='bar') 220 | url = reverse('subscriber_callback', args=[s.pk]) 221 | response = self.client.get(url, { 222 | 'hub.topic': 'foo', 223 | 'hub.mode': 'subscribe', 224 | 'hub.challenge': 'challenge yo', 225 | }) 226 | self.assertContains(response, 'Missing hub.lease_seconds parameter', 227 | status_code=400) 228 | 229 | def test_improper_lease_seconds(self): 230 | s = Subscription.objects.create(topic='foo', hub='bar') 231 | url = reverse('subscriber_callback', args=[s.pk]) 232 | response = self.client.get(url, { 233 | 'hub.topic': 'foo', 234 | 'hub.mode': 'subscribe', 235 | 'hub.challenge': 'challenge yo', 236 | 'hub.lease_seconds': 'yo', 237 | }) 238 | self.assertContains(response, 'hub.lease_seconds must be an integer', 239 | status_code=400) 240 | 241 | def test_verify_subscription(self): 242 | s = Subscription.objects.create(topic='foo', hub='bar') 243 | self.assertFalse(s.verified) 244 | self.assertIs(s.lease_expiration, None) 245 | self.assertFalse(s.has_expired()) 246 | 247 | url = reverse('subscriber_callback', args=[s.pk]) 248 | response = self.client.get(url, { 249 | 'hub.topic': 'foo', 250 | 'hub.mode': 'subscribe', 251 | 'hub.challenge': 'challenge yo', 252 | 'hub.lease_seconds': 12345, 253 | }) 254 | self.assertContains(response, 'challenge yo') 255 | 256 | s = Subscription.objects.get(pk=s.pk) 257 | self.assertTrue(s.verified) 258 | self.assertTrue( 259 | 12345 - (s.lease_expiration - timezone.now()).seconds < 3 260 | ) 261 | self.assertFalse(s.has_expired()) 262 | 263 | def test_verify_unsubscription(self): 264 | s = Subscription.objects.create(topic='foo', hub='bar') 265 | 266 | url = reverse('subscriber_callback', args=[s.pk]) 267 | response = self.client.get(url, { 268 | 'hub.topic': 'foo', 269 | 'hub.mode': 'unsubscribe', 270 | 'hub.challenge': 'challenge yo', 271 | }) 272 | self.assertEqual(response.content.decode(), 'challenge yo') 273 | self.assertEqual(response.status_code, 200) 274 | self.assertEqual(Subscription.objects.count(), 0) 275 | 276 | def test_payload_no_secret(self): 277 | s = Subscription.objects.create(topic='foo', hub='bar') 278 | url = reverse('subscriber_callback', args=[s.pk]) 279 | 280 | self.assertEqual(len(self.signals), 0) 281 | response = self.client.post(url, 'foo', content_type='text/plain') 282 | self.assertEqual(response.status_code, 200) 283 | self.assertEqual(len(self.signals), 1) 284 | sender, notification = self.signals[0][:2] 285 | self.assertEqual(sender, s) 286 | self.assertEqual(notification, b'foo') 287 | 288 | def test_payload_missing_secret(self): 289 | s = Subscription.objects.create(topic='foo', hub='bar', secret='lol') 290 | url = reverse('subscriber_callback', args=[s.pk]) 291 | 292 | response = self.client.post(url, 'foo', content_type='text/plain') 293 | self.assertEqual(response.status_code, 200) 294 | self.assertEqual(len(self.signals), 0) 295 | 296 | def test_payload_wrong_signature(self): 297 | s = Subscription.objects.create(topic='foo', hub='bar', secret='lol') 298 | url = reverse('subscriber_callback', args=[s.pk]) 299 | 300 | response = self.client.post(url, 'foo', content_type='text/plain', 301 | HTTP_X_HUB_SIGNATURE='sha1=deadbeef') 302 | self.assertEqual(response.status_code, 200) 303 | self.assertEqual(len(self.signals), 0) 304 | 305 | def test_payload_correct_signature(self): 306 | s = Subscription.objects.create(topic='foo', hub='bar', secret='lol') 307 | url = reverse('subscriber_callback', args=[s.pk]) 308 | 309 | sig = 'sha1=bfe9c8b0bc631a74dbc484c4e4a5a469cbb8b01f' 310 | response = self.client.post(url, 'foo', content_type='text/plain', 311 | HTTP_X_HUB_SIGNATURE=sig) 312 | self.assertEqual(response.status_code, 200) 313 | self.assertEqual(len(self.signals), 1) 314 | 315 | def test_payload_link_headers(self): 316 | s = Subscription.objects.create(topic='foo', hub='bar') 317 | url = reverse('subscriber_callback', args=[s.pk]) 318 | 319 | self.assertEqual(len(self.signals), 0) 320 | response = self.client.post( 321 | url, 'foo', content_type='text/plain', HTTP_LINK=( 322 | '; ' 323 | 'rel="self",; rel="hub"' 324 | )) 325 | self.assertEqual(response.status_code, 200) 326 | self.assertEqual(len(self.signals), 1) 327 | for link in self.signals[0][2]['links']: 328 | if link['rel'] == 'self': 329 | break 330 | self.assertEqual(link['url'], 331 | "http://joemygod.blogspot.com/feeds/posts/default") 332 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310}-dj{32} 4 | py{38,39,310,311}-dj{41} 5 | py{38,39,310,311}-dj{42} 6 | py{310,311}-dj{50,main} 7 | docs 8 | lint 9 | 10 | [testenv] 11 | commands = python -Wall -m coverage run runtests.py 12 | basepython = 13 | py38: python3.8 14 | py39: python3.9 15 | py310: python3.10 16 | py311: python3.11 17 | docs: python3.11 18 | lint: python3.11 19 | deps = 20 | coverage 21 | dj32: django~=3.2.9 22 | dj41: django~=4.1.3 23 | dj42: django~=4.2.0 24 | dj50: django~=5.0rc1 25 | djmain: https://github.com/django/django/archive/main.tar.gz 26 | 27 | [testenv:docs] 28 | changedir = docs 29 | deps = 30 | Sphinx 31 | sphinx_rtd_theme 32 | commands = 33 | sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 34 | 35 | [testenv:lint] 36 | deps = 37 | flake8 38 | commands = 39 | flake8 {toxinidir}/django_push {toxinidir}/tests 40 | 41 | [gh-actions] 42 | python = 43 | 3.8: py38 44 | 3.9: py39 45 | 3.10: py310 46 | 3.11: py311 47 | --------------------------------------------------------------------------------