kopia lustrzana https://gitlab.com/marnanel/chapeau
Merging trilby_api in (from un_chapeau).
Migrations work, and kepi's own tests run, but trilby's all fail.trilby
rodzic
52753c37d5
commit
f1e5d18f2b
|
@ -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:
|
||||
|
|
17
kepi/urls.py
17
kepi/urls.py
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TrilbyApiConfig(AppConfig):
|
||||
name = 'trilby_api'
|
|
@ -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)),
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
|
||||
python manage.py dumpdata trilby_api --indent 4 --output trilby_api/fixtures/alicebobcarol.json
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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]
|
|
@ -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()
|
|
@ -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"><p>{{ user.note | escape }}</p></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 }}"><p>{{ s.content | escape }}</p></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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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()),
|
||||
]
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
Ładowanie…
Reference in New Issue