From 8c11541674676b08824d5d755c09be527150e287 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Tue, 18 Jul 2017 16:35:33 +0200 Subject: [PATCH] Implement the person endpoint. GET /@ It returns the activitystream representation of a user (Person). --- activitypub/__init__.py | 0 activitypub/activities/__init__.py | 2 + activitypub/activities/errors.py | 8 ++ activitypub/activities/objects.py | 108 +++++++++++++++ activitypub/apps.py | 4 + activitypub/migrations/0001_initial.py | 23 ++++ .../migrations/0002_person_username.py | 27 ++++ activitypub/models.py | 63 +++++++++ activitypub/settings.py | 124 ++++++++++++++++++ activitypub/urls.py | 10 ++ activitypub/views.py | 17 +++ activitypub/wsgi.py | 16 +++ manage.py | 22 ++++ 13 files changed, 424 insertions(+) create mode 100644 activitypub/__init__.py create mode 100644 activitypub/activities/__init__.py create mode 100644 activitypub/activities/errors.py create mode 100644 activitypub/activities/objects.py create mode 100644 activitypub/apps.py create mode 100644 activitypub/migrations/0001_initial.py create mode 100644 activitypub/migrations/0002_person_username.py create mode 100644 activitypub/models.py create mode 100644 activitypub/settings.py create mode 100644 activitypub/urls.py create mode 100644 activitypub/views.py create mode 100644 activitypub/wsgi.py create mode 100755 manage.py diff --git a/activitypub/__init__.py b/activitypub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/activitypub/activities/__init__.py b/activitypub/activities/__init__.py new file mode 100644 index 0000000..590b9ef --- /dev/null +++ b/activitypub/activities/__init__.py @@ -0,0 +1,2 @@ +from .objects import * + diff --git a/activitypub/activities/errors.py b/activitypub/activities/errors.py new file mode 100644 index 0000000..2b61bea --- /dev/null +++ b/activitypub/activities/errors.py @@ -0,0 +1,8 @@ +class ASDecodeError(Exception): + pass + +class ASTypeError(Exception): + pass + +class ASValidateException(Exception): + pass diff --git a/activitypub/activities/objects.py b/activitypub/activities/objects.py new file mode 100644 index 0000000..fb2b6f1 --- /dev/null +++ b/activitypub/activities/objects.py @@ -0,0 +1,108 @@ +import json +from activitypub.activities import errors + +class Object(object): + attributes = ["type", "id", "name", "to"] + type = "Object" + + @classmethod + def from_json(cls, json): + return Object(**json) + + def __init__(self, obj=None, **kwargs): + if obj: + self.__init__(**obj.to_activitystream()) + for key in self.attributes: + if key == "type": + continue + + value = kwargs.get(key) + if value is None: + continue + + if isinstance(value, dict) and value.get("type"): + value = as_activitystream(value) + self.__setattr__(key, value) + + def __str__(self): + content = json.dumps(self, default=encode_activitystream) + return "<{type}: {content}>".format(type=self.type,content=content) + + def to_json(self, context=False): + values = {} + for attribute in self.attributes: + value = getattr(self, attribute, None) + if value is None: + continue + if isinstance(value, Object): + value = value.to_json() + # if getattr(value, "__iter__", None): + # value = [item.to_json() for item in value] + values[attribute] = value + to = values.get("to") + if isinstance(to, str): + values["to"] = [to] + elif getattr(to, "__iter__", None): + values["to"] = [] + for item in to: + if isinstance(item, str): + values["to"].append(item) + if isinstance(item, Object): + values["to"].append(item.id) + + if context: + values["@context"] = "https://www.w3.org/ns/activitystreams" + return values + + def to_activitystream(self): + return self + +class Actor(Object): + + attributes = Object.attributes + [ + "target", + + "preferredUsername", + "following", + "followers", + "outbox", + "inbox", + ] + type = "Actor" + + def send(self, activity): + res = requests.post(self.inbox, json=activity.to_json(context=True)) + if res.status_code != 200: + raise Exception + +class Person(Actor): + + type = "Person" + +######### +# Utils # +######### + +ALLOWED_TYPES = { + "Object": Object, + "Actor": Actor, + "Person": Person, +} + +def as_activitystream(obj): + type = obj.get("type") + + if not type: + msg = "Invalid ActivityStream object, the type is missing" + raise errors.ASDecodeError(msg) + + if type in ALLOWED_TYPES: + return ALLOWED_TYPES[type](**obj) + + raise errors.ASDecodeError("Invalid Type {0}".format(type)) + +def encode_activitystream(obj): + if isinstance(obj, Object): + return obj.to_json() + raise errors.ASTypeError("Unknown ActivityStream Type") + diff --git a/activitypub/apps.py b/activitypub/apps.py new file mode 100644 index 0000000..95a4c87 --- /dev/null +++ b/activitypub/apps.py @@ -0,0 +1,4 @@ +from django.apps import AppConfig + +class ActivityPubConfig(AppConfig): + name = 'activitypub' diff --git a/activitypub/migrations/0001_initial.py b/activitypub/migrations/0001_initial.py new file mode 100644 index 0000000..0c1c648 --- /dev/null +++ b/activitypub/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-16 15:53 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + ] diff --git a/activitypub/migrations/0002_person_username.py b/activitypub/migrations/0002_person_username.py new file mode 100644 index 0000000..bff524a --- /dev/null +++ b/activitypub/migrations/0002_person_username.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-16 16:16 +from __future__ import unicode_literals + +from django.db import migrations, models + +def usernames(apps, schema_editor): + Person = apps.get_model('activitypub', 'Person') + + for person in Person.objects.all(): + person.username = person.name + person.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('activitypub', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='username', + field=models.CharField(max_length=100, null=True), + ), + migrations.RunPython(usernames, reverse_code=migrations.RunPython.noop) + ] diff --git a/activitypub/models.py b/activitypub/models.py new file mode 100644 index 0000000..0072663 --- /dev/null +++ b/activitypub/models.py @@ -0,0 +1,63 @@ +from django.db.models import Model, ForeignKey, CharField, TextField, BooleanField +from django.db.models import ManyToManyField +from django.db.models.signals import post_save +from django.dispatch import receiver + +from django.conf import settings +from django.urls import reverse + +def uri(name, *args): + domain = settings.ACTIVITYPUB_DOMAIN + path = reverse(name, args=args) + return "http://{domain}{path}".format(domain=domain, path=path) + +class URIs(object): + + def __init__(self, **kwargs): + for attr, value in kwargs.items(): + setattr(self, attr, value) + +class Person(Model): + ap_id = TextField(null=True) + remote = BooleanField(default=False) + + username = CharField(max_length=100) + name = CharField(max_length=100) + following = ManyToManyField('self', symmetrical=False, related_name='followers') + + @property + def uris(self): + if self.remote: + return URIs(id=self.ap_id) + + return URIs( + id=uri("person", self.username), + following=uri("following", self.username), + followers=uri("followers", self.username), + outbox=uri("outbox", self.username), + inbox=uri("inbox", self.username), + ) + + def to_activitystream(self): + json = { + "id": self.uris.id, + "name": self.name, + "preferredUsername": self.username, + } + + if not self.remote: + json.update({ + "following": self.uris.following, + "followers": self.uris.followers, + "outbox": self.uris.outbox, + "inbox": self.uris.inbox, + }) + return json + +@receiver(post_save, sender=Person) +@receiver(post_save, sender=Note) +def save_ap_id(sender, instance, created, **kwargs): + if created and not instance.remote: + instance.ap_id = instance.uris.id + instance.save() + diff --git a/activitypub/settings.py b/activitypub/settings.py new file mode 100644 index 0000000..06c3f87 --- /dev/null +++ b/activitypub/settings.py @@ -0,0 +1,124 @@ +""" +Django settings for activitypub project. + +Generated by 'django-admin startproject' using Django 1.11.3. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '&@ie+jfg=w1_4jwq_!hkfa%r-%_j0%0sc(*fhhwqedx3(o+98p' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["alice.local", "bob.local"] +USE_X_FORWARDED_HOST = True +ACTIVITYPUB_DOMAIN = "alice.local" + + +# Application definition + +INSTALLED_APPS = [ + 'activitypub.apps.ActivityPubConfig', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'activitypub.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'activitypub.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/activitypub/urls.py b/activitypub/urls.py new file mode 100644 index 0000000..17615b6 --- /dev/null +++ b/activitypub/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url +from django.contrib import admin + +from activitypub.views import person, note, new_note, notes, inbox, outbox +from activitypub.views import followers, noop + +urlpatterns = [ + url(r'^@([^/]+)$', person, name="person"), + url(r'^admin/', admin.site.urls), +] diff --git a/activitypub/views.py b/activitypub/views.py new file mode 100644 index 0000000..331a496 --- /dev/null +++ b/activitypub/views.py @@ -0,0 +1,17 @@ +from urllib.parse import urlparse +import json +import requests + +from django.http import JsonResponse, HttpResponseRedirect, HttpResponseNotAllowed +from django.urls import reverse +from django.shortcuts import get_object_or_404, render +from django.views.decorators.csrf import csrf_exempt + +from activitypub.models import Person, Note +from activitypub import activities +from activitypub.activities import as_activitystream + +def person(request, username): + person = get_object_or_404(Person, username=username) + return JsonResponse(activities.Person(person).to_json(context=True)) + diff --git a/activitypub/wsgi.py b/activitypub/wsgi.py new file mode 100644 index 0000000..aa1b935 --- /dev/null +++ b/activitypub/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for activitypub project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "activitypub.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..5bc30aa --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "activitypub.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv)