Merging trilby_api in (from un_chapeau).

Migrations work, and kepi's own tests run, but trilby's all fail.
trilby
Marnanel Thurman 2019-10-04 21:59:45 +01:00
rodzic 52753c37d5
commit f1e5d18f2b
21 zmienionych plików z 2270 dodań i 16 usunięć

Wyświetl plik

@ -64,9 +64,15 @@ INSTALLED_APPS = (
'djcelery',
'django_celery_results',
'django_kepi',
'rest_framework',
'oauth2_provider',
'corsheaders',
'django_fields',
'polymorphic',
'django_kepi',
'trilby_api',
)
DATABASES = {
@ -136,6 +142,52 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'static')
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
AUTHENTICATION_BACKENDS = (
'oauth2_provider.backends.OAuth2Backend',
'django.contrib.auth.backends.ModelBackend',
)
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',
},
]
OAUTH2_PROVIDER = {
'SCOPES': {
'read': 'Read messages',
'write': 'Post messages',
'follow': 'Follow other users',
},
'ALLOWED_REDIRECT_URI_SCHEMES': ['urn', 'http', 'https'],
}
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
),
# 'PAGE_SIZE': 50,
}
AUTH_USER_MODEL = 'trilby_api.TrilbyUser'
try:
from .local_config import *
except ModuleNotFoundError:

Wyświetl plik

@ -1,28 +1,15 @@
"""kepi URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, re_path, include
from django.conf.urls.static import static
import django_kepi.urls
import trilby_api.urls
from . import settings
urlpatterns = [
path(r'admin/', admin.site.urls),
# path('', kepi.views.FrontPageView.as_view()), # or something
path(r'', include(django_kepi.urls)),
path(r'', include(django_kepi.urls)),
]
if settings.DEBUG:

Wyświetl plik

@ -13,3 +13,11 @@ django-celery-results
django-polymorphic
python-mimeparse
gunicorn
django-markdown
oauth2-provider
django-cors-headers
django-oauth-toolkit
django-rest-framework
djangorestframework-xml
djangorestframework-constant-field
django-fields

Wyświetl plik

Wyświetl plik

@ -0,0 +1,52 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
import trilby_api.models as models
class TrilbyUserChangeForm(UserChangeForm):
class Meta(UserChangeForm.Meta):
model = models.TrilbyUser
class TrilbyUserCreationForm(UserCreationForm):
class Meta(UserCreationForm.Meta):
model = models.TrilbyUser
fields = UserCreationForm.Meta.fields + (
'email',
)
class TrilbyUserAdmin(UserAdmin):
form = TrilbyUserChangeForm
add_form = TrilbyUserCreationForm
add_fieldsets = UserAdmin.add_fieldsets + (
(None, {
'classes': 'wide',
'fields': (
'email',
),
}),
)
fieldsets = UserAdmin.fieldsets + (
(None, {
'fields': (
'_avatar',
'_header',
'locked',
'note',
'linked_url',
'moved_to',
),
}),
('Relationships', {
'fields': (
('following', 'blocking',),
),
}),
)
admin.site.register(models.TrilbyUser)
admin.site.register(models.Status)
admin.site.register(models.MessageCapturer)

Wyświetl plik

@ -0,0 +1,5 @@
from django.apps import AppConfig
class TrilbyApiConfig(AppConfig):
name = 'trilby_api'

Wyświetl plik

@ -0,0 +1,67 @@
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA256
from struct import pack
import base64
import hashlib
def base64url_encode(data):
"""
base64-encodes its input, using the modified URL-safe
alphabet given in RFC 4648.
"""
b = base64.b64encode(s=data,
altchars=b'-_')
return str(b, encoding='ASCII')
def bignum_to_bytes(bignum):
temp = bignum
result = []
while temp!=0:
result.append(temp & 0xFF)
temp >>= 8
result.reverse()
return bytes(result)
class Key(object):
"""
An RSA public/private key pair, which can produce
a magic-envelope signature for itself.
I was going to subclass the RSA key object,
but it's hidden away and has an underscore prefix,
so I guess the developers thought that was a bad idea.
So I'm wrapping it, instead.
"""
def __init__(self):
self._rsa_key = RSA.generate(1024)
def private_as_pem(self):
return str(self._rsa_key.exportKey('PEM'),
encoding='ASCII')
def public_as_pem(self):
return str(self._rsa_key.publickey().exportKey('PEM'),
encoding='ASCII')
def modulus(self):
return self._rsa_key.n
def public_exponent(self):
return self._rsa_key.e
def private_exponent(self):
return self._rsa_key.d
def magic_envelope(self):
return 'RSA.{0}.{1}'.format(
base64url_encode(
bignum_to_bytes(
self._rsa_key.n)),
base64url_encode(
bignum_to_bytes(
self._rsa_key.e)),
)

Wyświetl plik

@ -0,0 +1,3 @@
#!/bin/bash
python manage.py dumpdata trilby_api --indent 4 --output trilby_api/fixtures/alicebobcarol.json

Wyświetl plik

@ -0,0 +1,235 @@
[
{
"model": "trilby_api.user",
"pk": 2,
"fields": {
"actor_id": null,
"password": "pbkdf2_sha256$100000$M0AKN3AhuYDH$Z//LMtbDAcvI5MQ6UXlbOvVP32K+sqKN8R1s9uvmiD0=",
"last_login": null,
"is_superuser": true,
"first_name": "Alice",
"last_name": "Test",
"is_staff": true,
"is_active": true,
"date_joined": "2018-05-28T14:18:21Z",
"username": "alice",
"email": "alice@example.com",
"display_name": "Alice Test",
"locked": false,
"note": "We'll see how deep the rabbithole really goes.",
"linked_url": "https://example.com/people/alice",
"moved_to": "",
"_avatar": "/static/testing/alice.jpg",
"_header": "/static/defaults/_header.jpg",
"public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDJtAZt9hgB/7bNozWtv9Er+Pnf\num97oM8cxRlqRqUXZk0wuST9A0eY5EUsN8j3qc6msZjDPSDQELr/U/o+zJLp/B8s\n7x3iHAHGD4LcQ9AbyDqbhX9JZkmwGx6PIVmbMDANmppqLik36V7cov6BuHz1gFpD\nP+iPjem4mph/KLwugQIDAQAB\n-----END PUBLIC KEY-----",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXwIBAAKBgQDJtAZt9hgB/7bNozWtv9Er+Pnfum97oM8cxRlqRqUXZk0wuST9\nA0eY5EUsN8j3qc6msZjDPSDQELr/U/o+zJLp/B8s7x3iHAHGD4LcQ9AbyDqbhX9J\nZkmwGx6PIVmbMDANmppqLik36V7cov6BuHz1gFpDP+iPjem4mph/KLwugQIDAQAB\nAoGBAI6sRaQAYCkBzTeWC8E0HmwhN/Z2NKdZL0clb/3JrLtphI5DWBOT/0/5n6hQ\naVouBdvJYcowcgZa3zr+FtPW9s9EKswd4M6VEg7Kb7yvd7iD+6Hl/KY5YkpRutJF\nZVBt20iJi3xi+5D0BvMImD3nE/Zl2MgAJWIBlRywfDOuKS7BAkEA4hIoq0RAbSgf\nVBzzWucpZa4o2Ll35tG9X5zQSmEjWuuIxipsiwcuyHURTEG+45TU/AasyDTvqqm6\nHhG4Z9SpaQJBAORoAzTfuF6Z7I3THSDLQjqWiE10K0qSkrcPf7fMlTdzheQdhS5T\nA4liwuAkpoRKxpvBw+OvCkgaqr34+Oqo4VkCQQC19kPBxofM1HSS8VJ3IoTRkOLT\nvkTiBoPUx5VnqNQaRGasik0fgkKHmqK3rFuHNq5PxNehteoKhd6GgWDaQfOxAkEA\n1KV9rsFGrlSR5qyRJtH11AQH3Ex2bZQuod39I0qF9b1I/0r4jltdJJBdLD8TBIF1\njNeGH7j8Uor5QarFW/tk6QJBAMEYab7c1YTagny4nrKoddPfNyjUF2a7HNHTnCQn\nrbOt963SPr1/dv602FwOzKHAWVw401PfaHWkclEROZTwwEc=\n-----END RSA PRIVATE KEY-----",
"default_sensitive": false,
"default_visibility": "P",
"groups": [],
"user_permissions": []
}
},
{
"model": "trilby_api.user",
"pk": 3,
"fields": {
"actor_id": null,
"password": "pbkdf2_sha256$100000$Z9PA4XcXjlUq$wvgaInG9H7kLeU8mYt0LPVSEP5ZNcH8sUJiFq9CU5Ro=",
"last_login": null,
"is_superuser": false,
"first_name": "Bob",
"last_name": "Test",
"is_staff": false,
"is_active": true,
"date_joined": "2018-05-28T14:20:04Z",
"username": "bob",
"email": "bob@example.com",
"display_name": "Bob Test",
"locked": false,
"note": "Short for Bob.",
"linked_url": "https://example.com/people/bob",
"moved_to": "",
"_avatar": "/static/testing/bob.jpg",
"_header": "/static/defaults/_header.jpg",
"public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDPPJ6uNXzpocC9Z1rue3sgZl/W\nnYHjbtkfQCUpdV9lgmtbOgpZrQos5sIB5QxUx+yRAXmdSRsD2q1Kaeeew5T+pv3h\nJKH4XMNZd2mZf1KAuHjPFBjCRGMUwdEEozSy8ZpDAg+jQ2ro8E7wgZ+wsYatSLbQ\n9SIkceGWqxyhabyIqwIDAQAB\n-----END PUBLIC KEY-----",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQDPPJ6uNXzpocC9Z1rue3sgZl/WnYHjbtkfQCUpdV9lgmtbOgpZ\nrQos5sIB5QxUx+yRAXmdSRsD2q1Kaeeew5T+pv3hJKH4XMNZd2mZf1KAuHjPFBjC\nRGMUwdEEozSy8ZpDAg+jQ2ro8E7wgZ+wsYatSLbQ9SIkceGWqxyhabyIqwIDAQAB\nAoGBAMMfZqDMh+JKhHlROVLWPOYSviYKg2Oq2RANi2/vrXScSYzJpzksLip80yqJ\niQTCgME/TEyFqsQEP6mS8ZyQtlUjoz8j6q/9TFvQrWWHRNgiD9bdXFQr0+F9kxr9\nhdm1h7F5Q0JTGCPwBmL8GXCO8KmbZUMejITNqsIxx3bvzUoRAkEA1Eul1Mwkb/6C\ncBuG0TPfXwluWdzS37XY+ckuQqgA5Jhj+vQTWbGovA/99W5GHHWsHJwGwcqqJJet\nc4slKN3GBwJBAPnmXpAO1NiQMlIVohTfjCusypsSiHJXocigJG/DeEuMIHUJebuH\nr6M7D2ENk7xh/kr3qA6369WokYT+K5Bqnz0CQQDPvApUVUIeeNwoWTcuBOVBeNgL\nhOKv16Cuo6bpwL3G8jt7OFSrAwZKqBdojvR6Ksc045RVEzw0PFuU4YaGG6UHAkBZ\nQ9LvfnzFRuzSqWuWLSwyxawxrHMU9PyTX7DkQ1yLD+jgJZxYQmWY1xXtQx5Momxl\ndwWPDF+vmGEysl/5XDy5AkEAwbbjj3YstPYPnBsAbI0XYHfXxu7aZ+bwPNtH48r3\n3YPAqM+HO0i2ffHjMJCYfbttWiRJRFVHyp3QO/Wtww4DQQ==\n-----END RSA PRIVATE KEY-----",
"default_sensitive": false,
"default_visibility": "P",
"groups": [],
"user_permissions": []
}
},
{
"model": "trilby_api.user",
"pk": 4,
"fields": {
"actor_id": null,
"password": "pbkdf2_sha256$100000$ufg0BY5asyK7$Qj4spNJu2CyR+jcdbO9kYJOZ7oqEQaErM4A//UuhZnc=",
"last_login": null,
"is_superuser": false,
"first_name": "Carol",
"last_name": "Test",
"is_staff": false,
"is_active": true,
"date_joined": "2018-05-28T14:21:04Z",
"username": "carol",
"email": "carol@example.com",
"display_name": "Carol Test",
"locked": false,
"note": "Every star shall sing a Carol.",
"linked_url": "https://example.com/people/carol",
"moved_to": "",
"_avatar": "/static/testing/carol.jpg",
"_header": "/static/defaults/_header.jpg",
"public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCsGqRZbZV2nljTOW2b77fzpkx9\n+iqcNgEnlhdgSIjCPvhzwj6cb0O5RVqPu6krvv+Dgiy89Mb0nregdOstUXzn40Yi\ncg1OOHMitrpAQ+4MsotqspfFZF/Q9qSFom3PxDA55lIHYJJmusVM6bSlrdY8msAs\nL1BieW7065gtjzv6RQIDAQAB\n-----END PUBLIC KEY-----",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXAIBAAKBgQCsGqRZbZV2nljTOW2b77fzpkx9+iqcNgEnlhdgSIjCPvhzwj6c\nb0O5RVqPu6krvv+Dgiy89Mb0nregdOstUXzn40Yicg1OOHMitrpAQ+4MsotqspfF\nZF/Q9qSFom3PxDA55lIHYJJmusVM6bSlrdY8msAsL1BieW7065gtjzv6RQIDAQAB\nAoGACURx/yLIfpeuPsmD3na9GBCnY805yCmcTE5nudaODq+nX0xhZLkVE3/pjX3U\ncTeauLEkyZQAtqFpT+mb1Ffj+t3exqK68k7UwCUI23Gtbr5dRUOivWuN75Sf4xFo\n7vQeSwIot/1PyU7JYXZ9Tq9WMBHcFocCdxu85QSBS40cPIECQQC60bIlj+bN7fvU\nHoPyQbj1vfjbz6vcoeR6v+YGCiSFrzawOEuJ1xUC7c/uivgJUvKumhrOFfwLac+1\nZay2t7khAkEA69X4ft06mfVNo1oM8ly33CO5TKlpFlVPBxEdKMHyZ2ZO1vIK6DsF\nN67YwYI9OoncTvBucZ+Dy2GU0xUeqtSopQJANhtnsjNUUI49onjYFEDutdW4jsk9\n6F/HEbokf9lOLJ3LhAw57Ikrn7aKw3biUakBeopNeySo5BFYRBxXgnABoQJBANlf\nMVoNo1QAy/zCpahGWZlovASzKY9SNjM3TP8iNMGlhQmNswv2SorWeCd0Wec45n1E\nEyhbdOjjGn+sucWPmZkCQD8ekBtzToRvNZjocFJhZdcWMIAo96XsnTAxREKSmbnG\npFHXBWokjDO/rTRbqANocLr0GwFR8UBD70CJLLOCaEg=\n-----END RSA PRIVATE KEY-----",
"default_sensitive": false,
"default_visibility": "P",
"groups": [],
"user_permissions": []
}
},
{
"model": "trilby_api.user",
"pk": 5,
"fields": {
"actor_id": null,
"password": "pbkdf2_sha256$100000$RUkxP556EAlO$IqiRKEAjs0AxQebO3ohhnGP9mQCf47LxjmW3V0j7gG8=",
"last_login": "2018-07-27T20:51:33.666Z",
"is_superuser": true,
"first_name": "",
"last_name": "",
"is_staff": true,
"is_active": true,
"date_joined": "2018-07-10T19:12:50.728Z",
"username": "marnanel",
"email": "marnanel@thurman.org.uk",
"display_name": "",
"locked": false,
"note": "",
"linked_url": "",
"moved_to": "",
"_avatar": "",
"_header": "",
"public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIUCqW/lyJ9eWkqvE7wpmHacu9\nXOOSWZsx/+B2MM/xQYpUUIMZ3cyI3yMSOa3MS14wMBWdxlWNIMF7gVKHO6L9Ppns\nBfTLbe/QMcssQ5rHv9oAMy/hWHGyaES3vbxzqT2qMxI5bIJRpOJfDlTpAY5AVqrn\n8sYx/1XA9YJOKFkQIQIDAQAB\n-----END PUBLIC KEY-----",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQDIUCqW/lyJ9eWkqvE7wpmHacu9XOOSWZsx/+B2MM/xQYpUUIMZ\n3cyI3yMSOa3MS14wMBWdxlWNIMF7gVKHO6L9PpnsBfTLbe/QMcssQ5rHv9oAMy/h\nWHGyaES3vbxzqT2qMxI5bIJRpOJfDlTpAY5AVqrn8sYx/1XA9YJOKFkQIQIDAQAB\nAoGAb5YY45w2uLO+hYikcYHqPMD2ujowm6pHBgdgZvayH9c+09E19wbTlbuXseNn\nqdKiWX2vzQBbUA7bOY3FB4h1NllyJlusNGcfdiz8zoW0gYl7pPJ2bUQrKjYO/fVV\ndvKqcdgy8Vsh8XQa1deAAauoAz5z6lqRvMgpFpDv9XQFGjECQQDcgKr8K6Yp1C+F\nt+NqOLvD168zEJiutxNtknl8jocntuNEfzkyDGF8OMEsVqsmXdffxs8mnkJZUJo3\nbrfMVlGTAkEA6I9z0lDJzdsUHgQdGov8H3t4Mp05YuczoOdzi3TNAaSW1ofwVpkw\n79V/zFrLQHYBJNrHcGFUBXTgxmOmQ123+wJBANwu146Lf5dRPEsIftw43rYHD/mr\n3urIAWxu0UUhhbCQnYxuhgyF9Gp4udyuhqT/HGtmOMBVU+ef5v7nXj17DGUCQHH4\ny7RIr33ZXfSP44t9CySKqA92Cge0cxLqGzA/H7EsblfY6yoljVwcb7NA09dVfZ4I\nMjGbIUhDTV0svPDK3z0CQQC1f6idZlB5IfvIhsXrCJuFpK6iqwNrtfjn5GHuMUXe\n8yvE/7Cuz76dFSMnIUPmvdO4ZRfrd4lmC3tRnGzGqZz4\n-----END RSA PRIVATE KEY-----",
"default_sensitive": false,
"default_visibility": "P",
"groups": [],
"user_permissions": []
}
},
{
"model": "trilby_api.status",
"pk": 3,
"fields": {
"posted_by": 2,
"content": "What's down the rabbit hole?",
"created_at": "2018-05-28T14:49:46.057Z",
"in_reply_to_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "X",
"idempotency_key": ""
}
},
{
"model": "trilby_api.status",
"pk": 4,
"fields": {
"posted_by": 2,
"content": "some pretty weird stuff tbh",
"created_at": "2018-05-28T14:50:19.294Z",
"in_reply_to_id": 3,
"sensitive": false,
"spoiler_text": "",
"visibility": "X",
"idempotency_key": ""
}
},
{
"model": "trilby_api.status",
"pk": 5,
"fields": {
"posted_by": 2,
"content": "@bob@localhost Do you know anything about playing cards?",
"created_at": "2018-05-28T14:51:00.762Z",
"in_reply_to_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "X",
"idempotency_key": ""
}
},
{
"model": "trilby_api.status",
"pk": 6,
"fields": {
"posted_by": 3,
"content": "@alice@localhost No, why?",
"created_at": "2018-05-28T14:51:21.110Z",
"in_reply_to_id": 5,
"sensitive": false,
"spoiler_text": "",
"visibility": "P",
"idempotency_key": ""
}
},
{
"model": "trilby_api.status",
"pk": 7,
"fields": {
"posted_by": 2,
"content": "@bob@localhost er, asking for a friend?",
"created_at": "2018-05-28T14:51:50.689Z",
"in_reply_to_id": 6,
"sensitive": false,
"spoiler_text": "",
"visibility": "X",
"idempotency_key": ""
}
},
{
"model": "trilby_api.status",
"pk": 8,
"fields": {
"posted_by": 3,
"content": "This is a private status...",
"created_at": "2018-05-28T14:52:31.913Z",
"in_reply_to_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "X",
"idempotency_key": ""
}
},
{
"model": "trilby_api.status",
"pk": 9,
"fields": {
"posted_by": 3,
"content": "he comes back three days later",
"created_at": "2018-05-28T14:53:08.142Z",
"in_reply_to_id": null,
"sensitive": false,
"spoiler_text": "spoilers for Mark's gospel",
"visibility": "P",
"idempotency_key": ""
}
},
{
"model": "trilby_api.status",
"pk": 10,
"fields": {
"posted_by": 3,
"content": "This status is unlisted.",
"created_at": "2018-05-28T14:53:54.504Z",
"in_reply_to_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "U",
"idempotency_key": ""
}
}
]

Wyświetl plik

@ -0,0 +1,19 @@
import json
data = json.load(open('alicebobcarol.json', 'r'))
result = []
count = 0
for person in data:
fields = person['fields']
if 'public_key' in fields and 'private_key' in fields:
result = {
'public': fields['public_key'],
'private': fields['private_key'],
}
json.dump(result,
open('keys-%04d.json' % (count,), 'w'),
sort_keys=True, indent=4)
count += 1

Wyświetl plik

@ -0,0 +1,104 @@
# Generated by Django 2.2.4 on 2019-10-04 20:57
from django.conf import settings
import django.contrib.auth.models
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import trilby_api.models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
]
operations = [
migrations.CreateModel(
name='TrilbyUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('username', models.CharField(max_length=255, unique=True)),
('email', models.EmailField(max_length=254, unique=True)),
('display_name', models.CharField(default='', max_length=255)),
('locked', models.BooleanField(default=False)),
('note', models.CharField(default='', max_length=255)),
('linked_url', models.URLField(default='', max_length=255)),
('moved_to', models.CharField(blank=True, default='', max_length=255)),
('_avatar', models.ImageField(blank=True, default=None, upload_to=trilby_api.models.avatar_upload_to)),
('_header', models.ImageField(blank=True, default=None, upload_to=trilby_api.models.header_upload_to)),
('public_key', models.CharField(editable=False, max_length=255)),
('private_key', models.CharField(editable=False, max_length=255)),
('default_sensitive', models.BooleanField(default=False)),
('default_visibility', models.CharField(choices=[('P', 'public'), ('X', 'private'), ('U', 'unlisted'), ('D', 'direct')], default='P', max_length=1)),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='MessageCapturer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('received_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('box', models.CharField(max_length=255)),
('content', models.TextField()),
('headers', models.TextField()),
],
),
migrations.CreateModel(
name='Person',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(max_length=255, unique=True)),
],
),
migrations.CreateModel(
name='Status',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField()),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('sensitive', models.BooleanField(default=None)),
('spoiler_text', models.CharField(blank=True, default='', max_length=255)),
('visibility', models.CharField(choices=[('P', 'public'), ('X', 'private'), ('U', 'unlisted'), ('D', 'direct')], default=None, max_length=1)),
('idempotency_key', models.CharField(blank=True, default='', max_length=255)),
('in_reply_to_id', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='trilby_api.Status')),
('posted_by', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'statuses',
},
),
migrations.AddField(
model_name='trilbyuser',
name='actor',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='trilby_api.Person'),
),
migrations.AddField(
model_name='trilbyuser',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
),
migrations.AddField(
model_name='trilbyuser',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
),
]

Wyświetl plik

@ -0,0 +1,556 @@
from enum import Enum
from random import randint
from datetime import datetime
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.timezone import now
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.core.files.images import ImageFile
from django.conf import settings
from trilby_api.crypto import Key
from django_kepi import implements_activity_type
#############################
class Visibility(Enum):
P = 'public'
X = 'private'
U = 'unlisted'
D = 'direct'
#############################
def iso_date(date):
return date.isoformat()+'Z'
#############################
def default_avatar(variation=0):
path = 'defaults/avatar_{0}.jpg'.format(variation%10)
return ImageFile(
open(
StaticFilesStorage().path(path), 'rb')
)
def default_header():
path = 'defaults/header.jpg'
return ImageFile(
open(
StaticFilesStorage().path(path), 'rb')
)
#############################
def avatar_upload_to(instance, filename):
return 'avatars/%s.jpg' % (
instance.username,
)
def header_upload_to(instance, filename):
return 'headers/%s.jpg' % (
instance.username,
)
@implements_activity_type('Person')
@implements_activity_type('Actor')
class Person(models.Model):
url = models.URLField(max_length=255,
unique=True)
@property
def is_local(self):
return self.user is not None
@property
def activity_id(self):
return self.url
@property
def activity_type(self):
return 'Person'
@property
def activity_form(self):
if self.user is None:
return {
'type': 'Person',
'id': self.url,
}
else:
return self.user.activity_form
@classmethod
def activity_find(cls, url):
return self.objects.get(url=url)
@classmethod
def activity_create(cls, url):
raise RuntimeError("you can't create people this way")
class TrilbyUser(AbstractUser):
REQUIRED_FIELDS = ['username']
# yes, the USERNAME_FIELD is the email, and not the username.
# it's an oauth2 thing. just roll with it.
USERNAME_FIELD = 'email'
actor = models.OneToOneField(
Person,
on_delete=models.CASCADE,
unique=True,
null=True,
)
username = models.CharField(max_length=255,
unique=True)
email = models.EmailField(
unique=True)
display_name = models.CharField(max_length=255,
default='')
locked = models.BooleanField(default=False)
note = models.CharField(max_length=255,
default='')
linked_url = models.URLField(max_length=255,
default='')
moved_to = models.CharField(max_length=255,
blank=True,
default='')
_avatar = models.ImageField(
upload_to = avatar_upload_to,
blank=True,
default=None,
)
_header = models.ImageField(
upload_to = header_upload_to,
blank=True,
default=None,
)
public_key = models.CharField(
max_length=255,
editable = False,
)
private_key = models.CharField(
max_length=255,
editable = False,
)
@property
def avatar(self):
if self._avatar is not None:
return self._avatar
else:
return default_avatar(variation=self.pw)
@property
def header(self):
if self._header is not None:
return self._header
else:
return default_header()
def save(self, *args, **kwargs):
if not self.private_key:
key = Key()
self.private_key = key.private_as_pem()
self.public_key = key.public_as_pem()
super().save(*args, **kwargs)
default_sensitive = models.BooleanField(
default=False)
default_visibility = models.CharField(
max_length = 1,
choices = [(tag.name, tag.value) for tag in Visibility],
default = Visibility('public').name,
)
def created_at(self):
# Alias for Django's date_joined. Mastodon calls this created_at,
# and it makes things easier if the model can call it that too.
return self.date_joined
def acct(self):
# XXX obviously we need to do something else for remote accounts
return '{0}@{1}'.format(
self.username,
settings.KEPI['LOCAL_OBJECT_HOSTNAME'],
)
def statuses(self):
return Status.objects.filter(posted_by=self)
def avatar_static(self):
# XXX
return self.avatar
def header_static(self):
# XXX
return self.header
def updated(self):
return Status.objects.filter(posted_by=self).latest('created_at').created_at
############### Relationship (friending, muting, blocking, etc)
@property
def followers(self):
return TrilbyUser.objects.filter(
actor__followers__following=self.actor,
)
@property
def following(self):
return TrilbyUser.objects.filter(
actor__following__follower=self.actor,
)
@property
def blocking(self):
return TrilbyUser.objects.filter(
actor__blockers__blocking=self.actor,
)
@property
def requesting_access(self):
return TrilbyUser.objects.filter(
actor__hopefuls__grantor=self.actor,
)
def block(self, someone):
"""
Blocks another user. The other user should
henceforth be unaware of our existence.
"""
blocking = django_kepi.models.Blocking(
blocking=self.actor,
blocked=someone.actor)
blocking.save()
def unblock(self, someone):
"""
Unblocks another user.
"""
django_kepi.models.Blocking.objects.filter(
following=self.actor,
follower=someone.actor,
).delete()
def is_blocking(self, someone):
return django_kepi.models.Blocking.objects.filter(
blocking=self.actor,
blocker=someone.actor,
).exists()
def follow(self, someone):
"""
Follows another user.
This has the side-effect of unblocking them;
I don't know whether that's reasonable.
If the other user's account is locked,
this will request access rather than following.
"""
if someone.is_blocking(self):
raise ValueError("Can't follow: blocked.")
if someone.locked:
req = django_kepi.models.RequestingAccess(
hopeful=self.actor,
grantor=someone.actor,
)
req.save()
else:
following = django_kepi.models.Following(
following=someone.actor,
follower=self.actor,
)
following.save()
def unfollow(self, someone):
django_kepi.models.Following.objects.filter(
following=someone.actor,
follower=self.actor,
).delete()
def is_following(self, someone):
return django_kepi.models.Following.objects.filter(
following=someone.actor,
follower=self.actor,
).exists()
def dealWithRequest(self, someone, accept=False):
if someone.is_following(self):
raise ValueError("They are already following you.")
if not django_kepi.models.RequestingAccess.objects.filter(
hopeful=someone.actor,
grantor=self.actor,
).exists():
raise ValueError("They haven't asked to follow you.")
if accept:
following = django_kepi.models.Following(
following=self.actor,
follower=someone.actor)
following.save()
django_kepi.models.RequestingAccess.objects.filter(
hopeful=someone.actor,
grantor=self.actor,
).delete()
#############################
def profileURL(self):
return settings.KEPI['LOCAL_OBJECT_HOSTNAME'] % {
'hostname': settings.KEPI['LOCAL_OBJECT_HOSTNAME'],
'username': self.username,
}
def feedURL(self):
return settings.KEPI['USER_FEED_URLS'].format(
username = self.username,
)
def activityURL(self):
return settings.KEPI['USER_ACTIVITY_URLS'].format(
username = self.username,
)
def featuredURL(self):
return settings.KEPI['USER_FEATURED_URLS'].format(
username = self.username,
)
def followersURL(self):
return settings.KEPI['USER_FOLLOWERS_URLS'].format(
username = self.username,
)
def followingURL(self):
return settings.KEPI['USER_FOLLOWING_URLS'].format(
username = self.username,
)
def inboxURL(self):
return settings.KEPI['USER_INBOX_URLS'].format(
username = self.username,
)
def outboxURL(self):
return settings.KEPI['USER_OUTBOX_URLS'].format(
username = self.username,
)
def links(self):
return [
{
'rel': 'http://webfinger.net/rel/profile-page',
'type': 'text/html',
'href': self.profileURL(),
},
{
'rel': 'http://schemas.google.com/g/2010#updates-from',
'type': 'application/atom+xml',
'href': self.feedURL(),
},
{
'rel': 'self',
'type': 'application/activity+json',
'href': self.activityURL(),
},
{
'rel': 'http://ostatus.org/schema/1.0/subscribe',
'template': settings.KEPI['AUTHORIZE_FOLLOW_TEMPLATE'],
},
]
#############################
#############################
class Status(models.Model):
class Meta:
verbose_name_plural = "statuses"
posted_by = models.ForeignKey(TrilbyUser,
on_delete = models.CASCADE,
default = 1, # to pacify makemigrations
)
# the spec calls this field "status", confusingly
content = models.TextField()
created_at = models.DateTimeField(default=now,
editable=False)
in_reply_to_id = models.ForeignKey('self',
on_delete = models.CASCADE,
blank = True,
null = True,
)
# XXX Media IDs here, when we've implemented Media
sensitive = models.BooleanField(default=None)
# applies to the media, not the text
spoiler_text = models.CharField(max_length=255, default='',
blank=True)
visibility = models.CharField(
max_length = 1,
choices = [(tag.name, tag.value) for tag in Visibility],
default = None,
)
idempotency_key = models.CharField(max_length=255, default='',
blank=True)
def save(self, *args, **kwargs):
if self.visibility is None:
self.visibility = self.posted_by.default_visibility
if self.sensitive is None:
self.sensitive = self.posted_by.default_sensitive
super().save(*args, **kwargs)
def is_sensitive(self):
return self.spoiler_text!='' or self.sensitive
def title(self):
"""
Returns the title of this status.
This isn't anything useful, but the Atom feed
requires it. So we return some vacuous string.
"""
return 'Status by %s' % (self.posted_by.username, )
def __str__(self):
return str(self.posted_by) + " - " + self.content
def _path_formatting(self, formatting):
return settings.KEPI[formatting].format(
username=self.posted_by.username,
id=self.id,
)
def url(self):
return self._path_formatting('STATUS_URLS')
def uri(self):
return self.url()
def emojis(self):
# I suppose we should do emojis eventually
return []
def reblog(self):
# XXX
return None
def reblogs_count(self):
# XXX change to a ResultSet
return 0
def favourites_count(self):
# XXX change to a ResultSet
return 0
def reblogged(self):
# XXX
return False
def favourited(self):
# XXX
return False
def muted(self):
# XXX
return False
def mentions(self):
# XXX
return []
def media_attachments(self):
# XXX
return []
def tags(self):
# XXX
return []
def language(self):
# XXX
return 'en'
def pinned(self):
# XXX
return False
def in_reply_to_account_id(self):
if self.in_reply_to_id is None:
return None
else:
return self.in_reply_to_id.posted_by.pk
def application(self):
# XXX
return None
def atomURL(self):
return self._path_formatting('STATUS_FEED_URLS')
def activityURL(self):
return self._path_formatting('STATUS_ACTIVITY_URLS')
def conversation(self):
"""
The string ID of the conversation this Status belongs to.
If we're a reply to another Status, we inherit the
conversation ID of that Status. Otherwise, we make up
our own ID, which should be a tag: URL as defined by RFC4151.
"""
# XXX check in_reply_to
now = datetime.now()
return 'tag:%s,%04d-%02d-%02d:objectId=%d:objectType=Conversation' % (
settings.KEPI['LOCAL_OBJECT_HOSTNAME'],
now.year,
now.month,
now.day,
self.id,
)
class MessageCapturer(models.Model):
received_at = models.DateTimeField(default=now,
editable=False)
box = models.CharField(max_length=255)
content = models.TextField()
headers = models.TextField()
def __str__(self):
return self.content[:100]

Wyświetl plik

@ -0,0 +1,206 @@
from rest_framework import serializers
from .models import TrilbyUser, Status, Visibility
from oauth2_provider.models import Application
#########################################
class _VisibilityField(serializers.CharField):
# Is there really no general enum field?
def to_representation(self, obj):
return Visibility[obj].value
def to_internal_value(self, obj):
try:
return Visibility(obj).name
except KeyError:
raise serializers.ValidationError('invalid visibility')
#########################################
class UserSerializer(serializers.ModelSerializer):
avatar = serializers.CharField(
read_only = True)
header = serializers.CharField(
read_only = True)
# for the moment, treat these as the same.
# the spec doesn't actually explain the difference!
avatar_static = serializers.CharField(source='avatar',
read_only = True)
header_static = serializers.CharField(source='header',
read_only = True)
url = serializers.URLField(source='linked_url')
following_count = serializers.SerializerMethodField()
followers_count = serializers.SerializerMethodField()
statuses_count = serializers.SerializerMethodField()
def get_following_count(self, obj):
return obj.following.count()
def get_followers_count(self, obj):
return obj.followers.count()
def get_statuses_count(self, obj):
return obj.statuses().count()
class Meta:
model = TrilbyUser
fields = (
'id',
'username',
'acct',
'display_name',
'email',
'locked',
'avatar',
'header',
'created_at',
'followers_count',
'following_count',
'statuses_count',
'note',
'url',
'avatar',
'avatar_static',
'header',
'header_static',
'moved_to',
)
#########################################
class UserSerializerWithSource(UserSerializer):
class Meta:
model = UserSerializer.Meta.model
fields = UserSerializer.Meta.fields + (
'source',
)
source = serializers.SerializerMethodField()
def get_source(self, instance):
return {
'privacy': instance.default_visibility,
'sensitive': instance.default_sensitive,
'note': instance.note,
}
#########################################
class StatusSerializer(serializers.ModelSerializer):
class Meta:
model = Status
fields = ('id', 'url', 'uri',
'account',
'in_reply_to_id',
'in_reply_to_account_id',
'reblog',
'status',
'content',
'created_at',
'emojis',
'reblogs_count',
'favourites_count',
'reblogged',
'favourited',
'muted',
'sensitive',
'spoiler_text',
'visibility',
'media_attachments',
'mentions',
'tags',
'application',
'language',
'pinned',
'idempotency_key',
)
def create(self, validated_data):
posted_by = self.context['request'].user
validated_data['posted_by'] = posted_by
result = Status.objects.create(**validated_data)
return result
id = serializers.IntegerField(
read_only = True)
account = UserSerializer(
source = 'posted_by',
read_only = True)
status = serializers.CharField(
write_only = True,
source = 'content')
content = serializers.CharField(
read_only = True)
created_at = serializers.DateTimeField(
read_only = True)
in_reply_to_id = serializers.PrimaryKeyRelatedField(
queryset=Status.objects.all,
required = False)
url = serializers.URLField(
read_only = True)
uri = serializers.URLField(
read_only = True)
# TODO Media
sensitive = serializers.BooleanField(
required = False)
spoiler_text = serializers.CharField(
required = False)
visibility = _VisibilityField(
required = False)
def visibility_validation(self, value):
if value not in Visibility:
raise serializers.ValidationError('invalid visibility')
return value
idempotency_key = serializers.CharField(
write_only = True,
required = False)
#########################################
class WebfingerSerializer(serializers.ModelSerializer):
# XXX need read_only=True
class Meta:
model = TrilbyUser
fields = [
'subject',
'aliases',
'links',
]
def get_subject(self, instance):
return 'acct:{}'.format(instance.acct())
def get_aliases(self, instance):
return [
instance.profileURL(),
]
def get_links(self, instance):
return instance.links()
subject = serializers.SerializerMethodField()
aliases = serializers.SerializerMethodField()
links = serializers.SerializerMethodField()

Wyświetl plik

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:thr="http://purl.org/syndication/thread/1.0"
xmlns:activity="http://activitystrea.ms/spec/1.0/"
xmlns:poco="http://portablecontacts.net/spec/1.0"
xmlns:media="http://purl.org/syndication/atommedia"
xmlns:ostatus="http://ostatus.org/schema/1.0"
xmlns:mastodon="http://mastodon.social/schema/1.0">
<id>{{ user.feedURL }}</id>
<title>{{ user.username }}</title>
<subtitle>{{ user.note }}</subtitle>
<updated>{{ user.updated |date:"Y-m-d\TH:i:s\Z" }}</updated>
<icon>{{ user.avatar }}</icon>
<logo>{{ user.avatar }}</logo>
<author>
<id>{{ user.profileURL }}</id>
<activity:object-type>http://activitystrea.ms/schema/1.0/person</activity:object-type>
<uri>{{ user.profileURL }}</uri>
<name>{{ user.username }}</name>
<email>{{ user.username }}@{{ server_name }}</email>
<summary type="html">&lt;p&gt;{{ user.note | escape }}&lt;/p&gt;</summary>
<link rel="avatar" type="image/jpeg" media:width="120" media:height="120" href="{{ user.avatar }}"/>
<link rel="header" type="image/jpeg" media:width="700" media:height="335" href="{{ user.header }}"/>
<poco:preferredUsername>{{ user.username }}</poco:preferredUsername>
<poco:displayName>{{ user.username }}</poco:displayName>
<poco:note>{{ user.note }}</poco:note>
<mastodon:scope>public</mastodon:scope>
</author>
<link rel="self" type="application/atom+xml" href="{{ user.feedURL }}"/>
<link rel="hub" href="{{ hubURL }}"/>
<link rel="salmon" href="{{ user.salmonURL }}"/>
{% for s in statuses %}
<entry>
<id>{{ s.url }}</id>
<published>{{ s.created_at|date:"Y-m-d\TH:i:s\Z" }}</published>
<updated>{{ s.created_at|date:"Y-m-d\TH:i:s\Z" }}</updated>
<title>{{ s.title }}</title>
<activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type>
<activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb>
<link rel="alternate" type="application/activity+json" href="{{ s.activityURL }}"/>
<link rel="alternate" type="text/html" href="{{ s.url }}"/>
<content type="html" xml:lang="{{ s.language }}">&lt;p&gt;{{ s.content | escape }}&lt;/p&gt;</content>
<link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" href="http://activityschema.org/collection/public" />
<mastodon:scope>public</mastodon:scope>
<link rel="self" type="application/atom+xml" href="{{ s.atomURL }}" />
<ostatus:conversation ref="{{ s.conversation }}" />
</entry>
{% endfor %}
</feed>

Wyświetl plik

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" type="application/xrd+xml" template="https://{{ server_name }}/.well-known/webfinger?resource={uri}"/>
</XRD>

Wyświetl plik

Wyświetl plik

@ -0,0 +1,343 @@
from django.test import TestCase, Client
from trilby_api.models import *
from django.conf import settings
import json
APPS_CREATE_PARAMS = {
'client_name': 'un_chapeau tests',
'scopes': 'read write follow',
'website': 'http://example.com',
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
}
TOKEN_REQUEST_PARAMS = {
'client_secret': '',
'client_id': '',
'grant_type': 'password',
'username': 'dory@example.com',
'password': 'i_like_bananas',
'scope': 'read write follow',
}
HTTP_AUTH = 'HTTP_AUTHORIZATION'
EXPECTED_CONTENT_TYPE = 'expected_content_type'
DEFAULT_EXPECTED_CONTENT_TYPE = 'application/json'
EXPECTED_STATUS_CODE = 'expected_status_code'
DEFAULT_EXPECTED_STATUS_CODE = 200
class UnChapeauClient(Client):
"""
Like an ordinary django.test.Client, except that:
- you can set 'authorization' to be an authorization string,
and it will be used in all future requests
- requests can have 'expected_content_type' and
'expected_status_code' parameters
"""
def __init__(self, **kwargs):
super().__init__(kwargs)
self.authorization = None
def request(self, **request):
"""
As the superclass, except for two more optional
parameters:
- expected_status_code causes ValueError if the
code doesn't match; None disables the check;
the default is 200.
- expected_content_type causes ValueError if the
Content-Type doesn't match; None disables the check;
the default is 'application/json'.
"""
if self.authorization is not None and HTTP_AUTH not in request:
request[HTTP_AUTH] = self.authorization
result = super().request(**request)
expected_status_code = request.get(EXPECTED_STATUS_CODE,
DEFAULT_EXPECTED_STATUS_CODE)
expected_content_type = request.get(EXPECTED_CONTENT_TYPE,
DEFAULT_EXPECTED_CONTENT_TYPE)
# XXX These should really be assertions, but
# XXX we don't have the TestCase object here. FIXME
if expected_status_code is not None:
if result.status_code != expected_status_code:
raise ValueError('status code: expected %d, got %d: %s' % (
expected_status_code, result.status_code,
str(result.content)))
elif expected_content_type is not None:
if result['Content-Type'] != expected_content_type:
raise ValueError('Content-Type: expected %s, got %s: %s' % (
expected_content_type, result['Content-Type'],
str(result.content)))
return result
def login(self,
token_params = TOKEN_REQUEST_PARAMS):
"""
Logs you in.
@param token_params Parameters for the token. This defaults
to TOKEN_REQUEST_PARAMS, which will log
you in as bob@example.com.
"""
self.app = self.post('/api/v1/apps', APPS_CREATE_PARAMS).json()
self.user = User.objects.create_user(
username=token_params['username'],
email=token_params['username'], # username is the email
password=token_params['password'])
for key in ['client_id', 'client_secret']:
token_params[key] = self.app[key]
self.token = self.post('/oauth/token', token_params).json()
self.authorization = 'Bearer '+self.token['access_token']
class AuthTests(TestCase):
fixtures = ['alicebobcarol']
def test_create_app(self):
c = UnChapeauClient()
app = c.post('/api/v1/apps', APPS_CREATE_PARAMS).json()
for key in ['client_id', 'client_secret', 'id']:
self.assertIn(key, app)
def test_create_token(self):
c = UnChapeauClient()
app = c.post('/api/v1/apps', APPS_CREATE_PARAMS).json()
token_params = TOKEN_REQUEST_PARAMS
self.user = User.objects.create_user(
username=token_params['username'],
email=token_params['username'],
password=token_params['password'])
for key in ['client_id', 'client_secret']:
token_params[key] = app[key]
token = c.post('/oauth/token', TOKEN_REQUEST_PARAMS).json()
for key in ['access_token','token_type','scope']:
self.assertIn(key, token)
self.assertEqual(token['scope'], token_params['scope'])
self.assertEqual(token['token_type'].lower(), 'bearer')
def test_create_token_fails(self):
c = UnChapeauClient()
app = c.post('/api/v1/apps', APPS_CREATE_PARAMS).json()
token_params = TOKEN_REQUEST_PARAMS
self.user = User.objects.create_user(
username=token_params['username'],
email=token_params['username'],
password=token_params['password'])
# add an "x" so we know it's not the real code
for key in ['client_id', 'client_secret']:
token_params[key] = app[key] + 'x'
token = c.post('/oauth/token',
TOKEN_REQUEST_PARAMS,
expected_status_code = 401)
def test_login(self):
c = UnChapeauClient()
app = c.post('/api/v1/apps', APPS_CREATE_PARAMS).json()
token_params = TOKEN_REQUEST_PARAMS
self.user = User.objects.create_user(
username=token_params['username'],
email=token_params['username'],
password=token_params['password'])
for key in ['client_id', 'client_secret']:
token_params[key] = app[key]
token = c.post('/oauth/token', token_params).json()
c.authorization = 'Bearer '+token['access_token']
account = c.get('/api/v1/accounts/verify_credentials').json()
for key in ['id', 'username', 'acct', 'display_name',
'locked', 'created_at', 'note', 'avatar',
'avatar_static', 'header', 'header_static',
'followers_count', 'following_count',
'source']:
self.assertIn(key, account)
for key in ['privacy', 'sensitive', 'note']:
self.assertIn(key, account['source'])
def test_login_fails(self):
c = UnChapeauClient()
app = c.post('/api/v1/apps', APPS_CREATE_PARAMS).json()
token_params = TOKEN_REQUEST_PARAMS
self.user = User.objects.create_user(
username=token_params['username'],
email=token_params['username'],
password=token_params['password'])
for key in ['client_id', 'client_secret']:
token_params[key] = app[key]
token = c.post('/oauth/token', token_params).json()
c.authorization = 'Bearer '+token['access_token']+'x'
account = c.get('/api/v1/accounts/verify_credentials',
# Error pages are text/html at present, for debugging;
# this is wrong, because they should be application/json,
# but we'll fix it when we reasonably can.
expected_content_type = 'text/html',
expected_status_code = 401)
class StatusTests(TestCase):
fixtures = ['alicebobcarol']
def test_post_status(self):
c = UnChapeauClient()
c.login()
status_params = {
'status': 'Hello world!',
}
status = c.post('/api/v1/statuses', status_params,
expected_status_code = 201).json()
for key in [
'id', 'uri', 'url', 'account', 'content',
'created_at', 'emojis', 'reblogs_count',
'favourites_count', 'sensitive',
'spoiler_text', 'visibility',
'media_attachments',
'mentions', 'tags']:
self.assertIn(key, status)
class UserTests(TestCase):
fixtures = ['alicebobcarol']
def test_public_timeline(self,
status_create_count = 20):
bob = User.objects.get(username='bob')
c = UnChapeauClient()
timeline = c.get('/api/v1/timelines/public').json()
# XXX right, and...?
class WebfingerTests(TestCase):
fixtures = ['alicebobcarol']
def request(self, resource, expected_status_code):
c = UnChapeauClient()
return c.get('/.well-known/webfinger?resource={}'.format(
resource),
expected_status_code=expected_status_code)
def test_weirdname(self):
self.request('womble', expected_status_code=404)
def test_remote_name(self):
self.request('un_chapeau@toot.love', expected_status_code=404)
def test_success(self):
c = UnChapeauClient()
checking_account = 'bob@' + settings.KEPI['LOCAL_OBJECT_HOSTNAME']
for prefix in ['', 'acct:']:
webfinger_response = self.request(
prefix+checking_account,
expected_status_code=200)
self.assertEqual(
webfinger_response.get('Content-Type'),
'application/jrd+json; charset=utf-8',
)
webfinger = json.loads(
webfinger_response.content.decode(
webfinger_response.charset))
for field in [
'subject',
'aliases',
'links',
]:
self.assertIn(field, webfinger)
for link in webfinger['links']:
self.assertIn('rel', link)
if link['rel']=='magic-public-key':
self.assertIn(
'data:application/magic-public-key,RSA',
link['href'],
)
if link['rel']=="http://ostatus.org/schema/1.0/subscribe":
self.assertIn(
'template',
link,
)
else:
self.assertIn(
'href',
link,
)
self.assertEqual(
webfinger['subject'],
'acct:{}'.format(
checking_account,
))
class HostMetaTests(TestCase):
def test_hostmeta(self):
c = UnChapeauClient()
result = c.get('/.well-known/host-meta',
expected_content_type='application/xrd+xml',
expected_status_code=200)
# XXX FIXME
# check Link is to webfinger

Wyświetl plik

@ -0,0 +1,146 @@
from django.test import TestCase, Client
from trilby_api.models import *
class UserTests(TestCase):
fixtures = ['alicebobcarol']
def test_status_count(self):
carol = User.objects.get(username="carol")
self.assertEqual(carol.statuses().count(), 0)
for i in range(1, 13):
Status.objects.create(
status='Hello world! {}'.format(i),
posted_by = carol,
)
self.assertEqual(carol.statuses().count(), i)
def test_following(self):
bob = User.objects.get(username="bob")
carol = User.objects.get(username="carol")
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), False)
bob.follow(carol)
self.assertEqual(bob.is_following(carol), True)
self.assertEqual(carol.is_following(bob), False)
carol.follow(bob)
self.assertEqual(bob.is_following(carol), True)
self.assertEqual(carol.is_following(bob), True)
bob.unfollow(carol)
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), True)
carol.unfollow(bob)
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), False)
def test_locked_account_accepted(self):
bob = User.objects.get(username="bob")
carol = User.objects.get(username="carol")
carol.locked = True
carol.save()
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), False)
bob.follow(carol)
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), False)
self.assertEqual(carol.requesting_access.filter(pk=bob.pk).exists(), True)
carol.dealWithRequest(bob, accept=True)
self.assertEqual(carol.requesting_access.filter(pk=bob.pk).exists(), False)
self.assertEqual(bob.is_following(carol), True)
self.assertEqual(carol.is_following(bob), False)
def test_locked_account_rejected(self):
bob = User.objects.get(username="bob")
carol = User.objects.get(username="carol")
carol.locked = True
carol.save()
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), False)
bob.follow(carol)
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), False)
self.assertEqual(carol.requesting_access.filter(pk=bob.pk).exists(), True)
carol.dealWithRequest(bob, accept=False)
self.assertEqual(carol.requesting_access.filter(pk=bob.pk).exists(), False)
self.assertEqual(bob.is_following(carol), False)
self.assertEqual(carol.is_following(bob), False)
class StatusTests(TestCase):
fixtures = ['alicebobcarol']
def test_sensitive_status(self):
alice = User.objects.get(username="alice")
for sensitive_by_default in (False, True):
if sensitive_by_default:
alice.default_sensitive = True
alice.save()
ordinary_status = Status.objects.create(
posted_by = alice,
status = 'I like cheese. It is delicious.',
)
self.assertEqual(ordinary_status.is_sensitive(),
sensitive_by_default)
nsfw_status = Status.objects.create(
posted_by = alice,
status = 'I was rather naughty today.',
sensitive = True,
)
self.assertEqual(nsfw_status.is_sensitive(), True)
spoiler_status = Status.objects.create(
posted_by = alice,
spoiler_text = 'Spoilers for Jekyll and Hyde',
status = 'They turn out to be the same guy.',
)
self.assertEqual(spoiler_status.is_sensitive(), True)
nsfw_spoiler_status = Status.objects.create(
posted_by = alice,
sensitive = True,
spoiler_text = 'Lex Luthor being naughty.',
status = 'He stole 40 cakes.',
)
self.assertEqual(nsfw_spoiler_status.is_sensitive(), True)
class TimelineTests(TestCase):
fixtures = ['alicebobcarol']
pass

13
trilby_api/urls.py 100644
Wyświetl plik

@ -0,0 +1,13 @@
from django.urls import path
from .views import *
endpoints = [
path('v1/instance', Instance.as_view()),
path('v1/apps', Apps.as_view()),
path('v1/accounts/verify_credentials', Verify_Credentials.as_view()),
path('v1/statuses', Statuses.as_view()),
path('v1/timelines/public', PublicTimeline.as_view()),
path('users/<username>/feed', UserFeed.as_view()),
]

401
trilby_api/views.py 100644
Wyświetl plik

@ -0,0 +1,401 @@
from django.shortcuts import render, get_object_or_404
from django.views import View
from django.http import HttpResponse, JsonResponse
from oauth2_provider.models import Application
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.datastructures import MultiValueDictKeyError
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
from .models import Status, TrilbyUser, Visibility, iso_date, MessageCapturer
from .serializers import *
from rest_framework import generics, response
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
import json
import re
###########################
class Instance(View):
def get(self, request, *args, **kwargs):
result = {
'uri': 'http://127.0.0.1',
'title': settings.KEPI['INSTANCE_NAME'],
'description': settings.KEPI['INSTANCE_DESCRIPTION'],
'email': settings.KEPI['CONTACT_EMAIL'],
'version': 'un_chapeau 0.0.1',
'urls': {},
'languages': settings.KEPI['LANGUAGES'],
'contact_account': settings.KEPI['CONTACT_ACCOUNT'],
}
return JsonResponse(result)
###########################
class Apps(View):
def post(self, request, *args, **kwargs):
new_app = Application(
name = request.POST['client_name'],
redirect_uris = request.POST['redirect_uris'],
client_type = 'confidential', # ?
authorization_grant_type = 'password',
user = None, # don't need to be logged in
)
new_app.save()
result = {
'id': new_app.id,
'client_id': new_app.client_id,
'client_secret': new_app.client_secret,
}
return JsonResponse(result)
class Verify_Credentials(generics.GenericAPIView):
queryset = TrilbyUser.objects.all()
def get(self, request):
serializer = UserSerializerWithSource(request.user)
return Response(serializer.data)
class Statuses(generics.ListCreateAPIView):
queryset = Status.objects.all()
serializer_class = StatusSerializer
class AbstractTimeline(generics.ListAPIView):
serializer_class = StatusSerializer
permission_classes = ()
def get_queryset(self):
raise RuntimeError("cannot query abstract timeline")
def list(self, request):
queryset = self.get_queryset()
serializer = self.serializer_class(queryset,
many = True,
context = {
'request': request,
})
return Response(serializer.data)
class PublicTimeline(AbstractTimeline):
permission_classes = ()
def get_queryset(self):
return Status.objects.filter(visibility=Visibility('public').name)
########################################
class UserFeed(View):
permission_classes = ()
def get(self, request, username, *args, **kwargs):
user = get_object_or_404(TrilbyUser, username=username)
statuses = Status.objects.filter(posted_by=user)
context = {
'user': user,
'statuses': statuses,
'server_name': settings.KEPI['HOSTNAME'],
'hubURL': settings.KEPI['HUB'],
}
result = render(
request=request,
template_name='account.atom.xml',
context=context,
content_type='application/atom+xml',
)
link_context = {
'hostname': settings.KEPI['HOSTNAME'],
'username': user.username,
'acct': user.acct(),
}
links = ', '.join(
[ '<{}>; rel="{}"; type="{}"'.format(
settings.KEPI.get(uri, username=user.username, acct=user.display_name),
rel, mimetype)
for uri, rel, mimetype in
[
('USER_WEBFINGER_URLS',
'lrdd',
'application/xrd+xml',
),
('USER_FEED_URLS',
'alternate',
'application/atom+xml',
),
('USER_FEED_URLS',
'alternate',
'application/activity+json',
),
]
])
result['Link'] = links
return result
########################################
class FIXMEview(View):
pass
########################################
class UserActivityView(FIXMEview):
permission_classes = ()
def objectDetails(self, *args, **kwargs):
username = kwargs['username']
user = get_object_or_404(TrilbyUser, username=username)
return {
"followers": user.followersURL(),
"outbox": user.outboxURL(),
"following": user.followingURL(),
"featured": user.featuredURL(),
"attachment": [],
"endpoints": {
"sharedInbox": settings.KEPI.get('SHARED_INBOX_URL'),
},
"tag": [],
"inbox": user.inboxURL(),
# XXX this dict should be coming from the image object
"image": {
"type": "Image",
# XXX enormous hack until we get media working properly
"url": "https://{}/static/defaults/header.jpg".format(settings.KEPI['HOSTNAME']),
#"url": user.header,
"mediaType": "image/jpeg",
},
"icon": {
"type": "Image",
# XXX enormous hack until we get media working properly
"url": "https://{}/static/defaults/avatar_1.jpg".format(settings.KEPI['HOSTNAME']),
#"url": user.avatar,
"mediaType": "image/jpeg",
},
"preferredUsername": user.username,
"type": "Person",
"id": user.activityURL(),
"summary": user.note,
"id": user.activityURL(),
"@context": UN_CHAPEAU["ATSIGN_CONTEXT"],
"publicKey": {
"id": '{}#main-key'.format(user.activityURL(),),
"owner": user.activityURL(),
"publicKeyPem": user.public_key,
},
"name": user.username,
"manuallyApprovesFollowers": user.default_sensitive,
}
########################################
class ActivityFollowingView(FIXMEview):
def get_collection_items(self, *args, **kwargs):
kwargs['url'] = settings.KEPI.get('USER_URLS',
username = kwargs['username']
)
return super().get_collection_items(*args, **kwargs)
def _stringify_object(self, obj):
return obj.following.url
class ActivityFollowersView(FIXMEview):
def get_collection_items(self, *args, **kwargs):
kwargs['url'] = settings.KEPI.get('USER_URLS',
username = kwargs['username']
)
return super().get_collection_items(*args, **kwargs)
def _stringify_object(self, obj):
return obj.follower.url
class ActivityOutboxView(FIXMEview):
def get_collection_items(self, *args, **kwargs):
user = get_object_or_404(TrilbyUser, username=kwargs['username'])
return Status.objects.filter(posted_by=user)
def _stringify_object(self, obj):
# XXX We'll do this properly soon.
# It should have views particular to each kind of Status,
# and an integration with the Activities in django_kepi.
return {
"object" : {
"atomUri" : obj.atomURL(),
"id" : obj.activityURL(),
"sensitive" : obj.is_sensitive(),
"attachment" : [],
"contentMap" : {
"en" : obj.content,
},
"url" : obj.url(),
"content" : obj.content,
"inReplyTo" : None,
"published" : iso_date(obj.created_at),
"inReplyToAtomUri" : None,
"to" : [
"https://www.w3.org/ns/activitystreams#Public"
],
"type" : "Note",
"cc" : [
obj.posted_by.followersURL(),
],
"attributedTo" : obj.posted_by.activityURL(),
"tag" : [],
"conversation" : obj.conversation(),
},
"published" : iso_date(obj.created_at),
"id" : obj.activityURL(),
"type" : "Create",
"to" : [
"https://www.w3.org/ns/activitystreams#Public"
],
"actor" : obj.posted_by.activityURL(),
"cc" : [
obj.posted_by.followersURL(),
]
}
class FeaturedCollectionView(FIXMEview):
# I have no idea what this is, and it doesn't seem to be in the specs.
# But Mastodon expects it, so...
def get_collection_items(self, *args, **kwargs):
return Status.objects.none()
########################################
class Webfinger(generics.GenericAPIView):
"""
RFC7033 webfinger support.
"""
serializer_class = WebfingerSerializer
permission_classes = ()
renderer_classes = (JSONRenderer, )
def _get_body(self, request):
try:
user = request.GET['resource']
except MultiValueDictKeyError:
return HttpResponse(
status = 400,
reason = 'no resource for webfinger',
content = 'no resource for webfinger',
content_type = 'text/plain',
)
# Generally, user resources should be prefaced with "acct:",
# per RFC7565. We support this, but we don't enforce it.
user = re.sub(r'^acct:', '', user)
if '@' not in user:
return HttpResponse(
status = 404,
reason = 'absolute name required',
content = 'Please use the absolute form of the username.',
content_type = 'text/plain',
)
username, hostname = user.split('@', 2)
if hostname!=settings.KEPI['HOSTNAME']:
return HttpResponse(
status = 404,
reason = 'not this server',
content = 'That user lives on another server.',
content_type = 'text/plain',
)
try:
queryset = TrilbyUser.objects.get(username=username)
except TrilbyUser.DoesNotExist:
return HttpResponse(
status = 404,
reason = 'no such user',
content = 'We don\'t have a user with that name.',
content_type = 'text/plain',
)
serializer = self.serializer_class(queryset)
return Response(serializer.data,
content_type='application/jrd+json; charset=utf-8')
def get(self, request):
result = self._get_body(request)
result['Access-Control-Allow-Origin'] = '*'
return result
########################################
class HostMeta(View):
permission_classes = ()
def get(self, request):
context = {
'server_name': settings.KEPI['HOSTNAME'],
}
result = render(
request=request,
template_name='host-meta.xml',
context=context,
content_type='application/jrd+xml',
)
return result
########################################
class MessageCapturingView(View):
def post(self, request, *args, **kwargs):
capture = MessageCapturer(
box = request.path,
content = str(request.body, encoding='UTF-8'),
headers = str(request.META),
)
capture.save()
return HttpResponse(
status = 200,
reason = 'Thank you',
content = '',
content_type = 'text/plain',
)