diff --git a/lemoncurry/settings/base.py b/lemoncurry/settings/base.py index 6f87f58..192cf85 100644 --- a/lemoncurry/settings/base.py +++ b/lemoncurry/settings/base.py @@ -89,6 +89,7 @@ INSTALLED_APPS = [ 'lemonauth', 'micropub', 'users', + 'webmention', 'wellknowns', ] diff --git a/lemoncurry/urls.py b/lemoncurry/urls.py index d44c4ef..6d2ef31 100644 --- a/lemoncurry/urls.py +++ b/lemoncurry/urls.py @@ -41,6 +41,7 @@ urlpatterns = [ url('^auth/', include('lemonauth.urls')), url('^micropub', include('micropub.urls')), url('^s/', include('shorturls.urls')), + url('^webmention', include('webmention.urls')), url(r'^django-rq/', include('django_rq.urls')), url(r'^sitemap\.xml$', sitemap.index, maps, name='sitemap'), diff --git a/webmention/__init__.py b/webmention/__init__.py new file mode 100644 index 0000000..6f47eb2 --- /dev/null +++ b/webmention/__init__.py @@ -0,0 +1 @@ +default_app_config = 'webmention.apps.WebmentionConfig' diff --git a/webmention/apps.py b/webmention/apps.py new file mode 100644 index 0000000..4d88f81 --- /dev/null +++ b/webmention/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WebmentionConfig(AppConfig): + name = 'webmention' diff --git a/webmention/migrations/0001_initial.py b/webmention/migrations/0001_initial.py new file mode 100644 index 0000000..ce76889 --- /dev/null +++ b/webmention/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.10 on 2018-03-19 01:18 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('entries', '0011_auto_20171120_1108'), + ] + + operations = [ + migrations.CreateModel( + name='Webmention', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('source', models.CharField(max_length=255)), + ('target', models.CharField(max_length=255)), + ('state', models.CharField(choices=[('p', 'pending'), ('v', 'valid'), ('i', 'invalid'), ('d', 'deleted')], default='p', max_length=1)), + ('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mentions', to='entries.Entry')), + ], + options={ + 'default_related_name': 'mentions', + }, + ), + migrations.AlterUniqueTogether( + name='webmention', + unique_together=set([('source', 'target')]), + ), + ] diff --git a/webmention/migrations/__init__.py b/webmention/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webmention/models.py b/webmention/models.py new file mode 100644 index 0000000..47690ab --- /dev/null +++ b/webmention/models.py @@ -0,0 +1,31 @@ +from django.db import models +from entries.models import Entry +from model_utils.models import TimeStampedModel + + +class State: + PENDING = 'p' + VALID = 'v' + INVALID = 'i' + DELETED = 'd' + CHOICES = ( + (PENDING, 'pending'), + (VALID, 'valid'), + (INVALID, 'invalid'), + (DELETED, 'deleted'), + ) + + +class Webmention(TimeStampedModel): + entry = models.ForeignKey(Entry, on_delete=models.CASCADE) + source = models.CharField(max_length=255) + target = models.CharField(max_length=255) + state = models.CharField( + choices=State.CHOICES, + default=State.PENDING, + max_length=1 + ) + + class Meta: + default_related_name = 'mentions' + unique_together = ('source', 'target') diff --git a/webmention/urls.py b/webmention/urls.py new file mode 100644 index 0000000..9af094e --- /dev/null +++ b/webmention/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url +from . import views + +app_name = 'webmention' +urlpatterns = ( + url('^$', views.accept, name='accept'), + url('^s/(?P\d+)$', views.status, name='status') +) diff --git a/webmention/views.py b/webmention/views.py new file mode 100644 index 0000000..80c15cd --- /dev/null +++ b/webmention/views.py @@ -0,0 +1,68 @@ +from django.http import HttpResponse +from django.urls import resolve, reverse, Resolver404 +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_POST +from entries.models import Entry +from lemoncurry.utils import bad_req +from urllib.parse import urljoin, urlparse + +from .models import State, Webmention + + +@csrf_exempt +@require_POST +def accept(request): + if 'source' not in request.POST: + return bad_req('missing source url') + source_url = request.POST['source'] + + if 'target' not in request.POST: + return bad_req('missing target url') + target_url = request.POST['target'] + + source = urlparse(source_url) + target = urlparse(target_url) + if source.scheme not in ('http', 'https'): + return bad_req('unsupported source scheme') + if target.scheme not in ('http', 'https'): + return bad_req('unsupported target scheme') + if target.netloc != request.site.domain: + return bad_req('target not on this site') + origin = 'https://' + target.netloc + + try: + match = resolve(target.path) + except Resolver404: + return bad_req('target not found') + + if match.view_name != 'entries:entry': + return bad_req('target does not accept webmentions') + + try: + entry = Entry.objects.get(pk=match.kwargs['id']) + except Entry.DoesNotExist: + return bad_req('target not found') + + try: + mention = Webmention.objects.get(source=source_url, target=target_url) + except Webmention.DoesNotExist: + mention = Webmention() + mention.source = source_url + mention.target = target_url + + mention.entry = entry + mention.state = State.PENDING + mention.save() + status = reverse('webmention:status', kwargs={'id': mention.id}) + + res = HttpResponse(status=201) + res['Location'] = urljoin(origin, status) + return res + + +@csrf_exempt +@require_GET +def status(request, id): + mention = get_object_or_404(Webmention.objects, pk=id) + return HttpResponse(mention.get_state_display())