diff --git a/activities/models/post_attachment.py b/activities/models/post_attachment.py index 932ae65..120a1d1 100644 --- a/activities/models/post_attachment.py +++ b/activities/models/post_attachment.py @@ -95,5 +95,5 @@ class PostAttachment(StatorModel): "width": self.width, "height": self.height, "mediaType": self.mimetype, - "http://joinmastodon.org/ns#focalPoint": [0.5, 0.5], + "http://joinmastodon.org/ns#focalPoint": [0, 0], } diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..072abb0 --- /dev/null +++ b/api/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from api.models import Application, Token + + +@admin.register(Application) +class ApplicationAdmin(admin.ModelAdmin): + list_display = ["id", "name", "website", "created"] + + +@admin.register(Token) +class TokenAdmin(admin.ModelAdmin): + list_display = ["id", "user", "application", "created"] diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..878e7d5 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api" diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 0000000..e9d37f3 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,87 @@ +# Generated by Django 4.1.3 on 2022-12-11 03:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("users", "0003_identity_followers_etc"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Application", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.CharField(max_length=500)), + ("client_secret", models.CharField(max_length=500)), + ("redirect_uris", models.TextField()), + ("scopes", models.TextField()), + ("name", models.CharField(max_length=500)), + ("website", models.CharField(blank=True, max_length=500, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="Token", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token", models.CharField(max_length=500)), + ("code", models.CharField(blank=True, max_length=100, null=True)), + ("scopes", models.JSONField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tokens", + to="api.application", + ), + ), + ( + "identity", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="tokens", + to="users.identity", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000..663cd7e --- /dev/null +++ b/api/models/__init__.py @@ -0,0 +1,2 @@ +from .application import Application # noqa +from .token import Token # noqa diff --git a/api/models/application.py b/api/models/application.py new file mode 100644 index 0000000..89bea5f --- /dev/null +++ b/api/models/application.py @@ -0,0 +1,19 @@ +from django.db import models + + +class Application(models.Model): + """ + OAuth applications + """ + + client_id = models.CharField(max_length=500) + client_secret = models.CharField(max_length=500) + + redirect_uris = models.TextField() + scopes = models.TextField() + + name = models.CharField(max_length=500) + website = models.CharField(max_length=500, blank=True, null=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) diff --git a/api/models/token.py b/api/models/token.py new file mode 100644 index 0000000..dc57cec --- /dev/null +++ b/api/models/token.py @@ -0,0 +1,39 @@ +from django.db import models + + +class Token(models.Model): + """ + An (access) token to call the API with. + + Can be either tied to a user, or app-level only. + """ + + application = models.ForeignKey( + "api.Application", + on_delete=models.CASCADE, + related_name="tokens", + ) + + user = models.ForeignKey( + "users.User", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="tokens", + ) + + identity = models.ForeignKey( + "users.Identity", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="tokens", + ) + + token = models.CharField(max_length=500) + code = models.CharField(max_length=100, blank=True, null=True) + + scopes = models.JSONField() + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) diff --git a/api/parser.py b/api/parser.py new file mode 100644 index 0000000..63e283f --- /dev/null +++ b/api/parser.py @@ -0,0 +1,20 @@ +import json + +from ninja.parser import Parser + + +class FormOrJsonParser(Parser): + """ + If there's form data in a request, makes it into a JSON dict. + This is needed as the Mastodon API allows form data OR json body as input. + """ + + def parse_body(self, request): + # Did they submit JSON? + if request.content_type == "application/json": + return json.loads(request.body) + # Fall back to form data + value = {} + for key, item in request.POST.items(): + value[key] = item + return value diff --git a/api/views/__init__.py b/api/views/__init__.py new file mode 100644 index 0000000..d661e7c --- /dev/null +++ b/api/views/__init__.py @@ -0,0 +1,3 @@ +from .apps import * # noqa +from .base import api # noqa +from .instance import * # noqa diff --git a/api/views/apps.py b/api/views/apps.py new file mode 100644 index 0000000..33ecf0f --- /dev/null +++ b/api/views/apps.py @@ -0,0 +1,37 @@ +import secrets + +from ninja import Field, Schema + +from ..models import Application +from .base import api + + +class CreateApplicationSchema(Schema): + client_name: str + redirect_uris: str + scopes: None | str = None + website: None | str = None + + +class ApplicationSchema(Schema): + id: str + name: str + website: str | None + client_id: str + client_secret: str + redirect_uri: str = Field(alias="redirect_uris") + + +@api.post("/v1/apps", response=ApplicationSchema) +def add_app(request, details: CreateApplicationSchema): + client_id = "tk-" + secrets.token_urlsafe(16) + client_secret = secrets.token_urlsafe(40) + application = Application.objects.create( + name=details.client_name, + website=details.website, + client_id=client_id, + client_secret=client_secret, + redirect_uris=details.redirect_uris, + scopes=details.scopes or "read", + ) + return application diff --git a/api/views/base.py b/api/views/base.py new file mode 100644 index 0000000..e9a087d --- /dev/null +++ b/api/views/base.py @@ -0,0 +1,5 @@ +from ninja import NinjaAPI + +from api.parser import FormOrJsonParser + +api = NinjaAPI(parser=FormOrJsonParser()) diff --git a/api/views/instance.py b/api/views/instance.py new file mode 100644 index 0000000..5923d30 --- /dev/null +++ b/api/views/instance.py @@ -0,0 +1,56 @@ +from django.conf import settings + +from activities.models import Post +from core.models import Config +from takahe import __version__ +from users.models import Domain, Identity + +from .base import api + + +@api.get("/v1/instance") +@api.get("/v1/instance/") +def instance_info(request): + return { + "uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN), + "title": Config.system.site_name, + "short_description": "", + "description": "", + "email": "", + "version": __version__, + "urls": {}, + "stats": { + "user_count": Identity.objects.filter(local=True).count(), + "status_count": Post.objects.filter(local=True).count(), + "domain_count": Domain.objects.count(), + }, + "thumbnail": Config.system.site_banner, + "languages": ["en"], + "registrations": ( + Config.system.signup_allowed and not Config.system.signup_invite_only + ), + "approval_required": False, + "invites_enabled": False, + "configuration": { + "accounts": {}, + "statuses": { + "max_characters": Config.system.post_length, + "max_media_attachments": 4, + "characters_reserved_per_url": 23, + }, + "media_attachments": { + "supported_mime_types": [ + "image/apng", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + ], + "image_size_limit": (1024**2) * 10, + "image_matrix_limit": 2000 * 2000, + }, + }, + "contact_account": None, + "rules": [], + } diff --git a/api/views/oauth.py b/api/views/oauth.py new file mode 100644 index 0000000..6be2778 --- /dev/null +++ b/api/views/oauth.py @@ -0,0 +1,105 @@ +import secrets +from urllib.parse import urlparse + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect, JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import TemplateView, View + +from api.models import Application, Token + + +class OauthRedirect(HttpResponseRedirect): + def __init__(self, redirect_uri, key, value): + self.allowed_schemes = [urlparse(redirect_uri).scheme] + super().__init__(redirect_uri + f"?{key}={value}") + + +class AuthorizationView(LoginRequiredMixin, TemplateView): + """ + Asks the user to authorize access. + + Could maybe be a FormView, but things are weird enough we just handle the + POST manually. + """ + + template_name = "api/oauth_authorize.html" + + def get_context_data(self): + redirect_uri = self.request.GET["redirect_uri"] + scope = self.request.GET.get("scope", "read") + try: + application = Application.objects.get( + client_id=self.request.GET["client_id"] + ) + except (Application.DoesNotExist, KeyError): + return OauthRedirect(redirect_uri, "error", "invalid_application") + return { + "application": application, + "redirect_uri": redirect_uri, + "scope": scope, + "identities": self.request.user.identities.all(), + } + + def post(self, request): + # Grab the application and other details again + redirect_uri = self.request.POST["redirect_uri"] + scope = self.request.POST["scope"] + application = Application.objects.get(client_id=self.request.POST["client_id"]) + # Get the identity + identity = self.request.user.identities.get(pk=self.request.POST["identity"]) + # Make a token + token = Token.objects.create( + application=application, + user=self.request.user, + identity=identity, + token=secrets.token_urlsafe(32), + code=secrets.token_urlsafe(16), + scopes=scope.split(), + ) + # Redirect with the token's code + return OauthRedirect(redirect_uri, "code", token.code) + + +@method_decorator(csrf_exempt, name="dispatch") +class TokenView(View): + def post(self, request): + grant_type = request.POST["grant_type"] + scopes = set(self.request.POST.get("scope", "read").split()) + try: + application = Application.objects.get( + client_id=self.request.POST["client_id"] + ) + except (Application.DoesNotExist, KeyError): + return JsonResponse({"error": "invalid_client_id"}, status=400) + # TODO: Implement client credentials flow + if grant_type == "client_credentials": + return JsonResponse({"error": "invalid_grant_type"}, status=400) + elif grant_type == "authorization_code": + code = request.POST["code"] + # Retrieve the token by code + # TODO: Check code expiry based on created date + try: + token = Token.objects.get(code=code, application=application) + except Token.DoesNotExist: + return JsonResponse({"error": "invalid_code"}, status=400) + # Verify the scopes match the token + if scopes != set(token.scopes): + return JsonResponse({"error": "invalid_scope"}, status=400) + # Update the token to remove its code + token.code = None + token.save() + # Return them the token + return JsonResponse( + { + "access_token": token.token, + "token_type": "Bearer", + "scope": " ".join(token.scopes), + "created_at": int(token.created.timestamp()), + } + ) + + +class RevokeTokenView(View): + pass diff --git a/requirements.txt b/requirements.txt index 8132acb..d24b45d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,10 @@ blurhash-python~=1.1.3 cryptography~=38.0 dj_database_url~=1.0.0 django-cache-url~=3.4.2 +django-cors-headers~=3.13.0 django-htmx~=1.13.0 +django-ninja~=0.19.1 +django-oauth-toolkit~=2.2.0 django-storages[google,boto3]~=1.13.1 django~=4.1 email-validator~=1.3.0 diff --git a/takahe/settings.py b/takahe/settings.py index 64a523a..e2e9b43 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -169,16 +169,19 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "django_htmx", + "corsheaders", "core", "activities", - "users", - "stator", + "api", "mediaproxy", + "stator", + "users", ] MIDDLEWARE = [ "core.middleware.SentryTaggingMiddleware", "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -278,6 +281,7 @@ AUTO_ADMIN_EMAIL = SETUP.AUTO_ADMIN_EMAIL STATOR_TOKEN = SETUP.STATOR_TOKEN +CORS_ORIGIN_ALLOW_ALL = True # Temporary CORS_ORIGIN_WHITELIST = SETUP.CORS_HOSTS CORS_ALLOW_CREDENTIALS = True CORS_PREFLIGHT_MAX_AGE = 604800 @@ -288,6 +292,7 @@ MEDIA_URL = SETUP.MEDIA_URL MEDIA_ROOT = SETUP.MEDIA_ROOT MAIN_DOMAIN = SETUP.MAIN_DOMAIN + if SETUP.USE_PROXY_HEADERS: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/takahe/urls.py b/takahe/urls.py index 762e091..57af7a7 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -4,6 +4,7 @@ from django.urls import path, re_path from django.views.static import serve from activities.views import compose, explore, follows, posts, search, timelines +from api.views import api, oauth from core import views as core from mediaproxy import views as mediaproxy from stator import views as stator @@ -201,6 +202,11 @@ urlpatterns = [ path("actor/", activitypub.SystemActorView.as_view()), path("actor/inbox/", activitypub.Inbox.as_view()), path("inbox/", activitypub.Inbox.as_view(), name="shared_inbox"), + # API/Oauth + path("api/", api.urls), + path("oauth/authorize", oauth.AuthorizationView.as_view()), + path("oauth/token", oauth.TokenView.as_view()), + path("oauth/revoke_token", oauth.RevokeTokenView.as_view()), # Stator path(".stator/", stator.RequestRunner.as_view()), # Django admin diff --git a/templates/api/oauth_authorize.html b/templates/api/oauth_authorize.html new file mode 100644 index 0000000..41aac0d --- /dev/null +++ b/templates/api/oauth_authorize.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Authorize {{ application.name }}{% endblock %} + +{% block content %} + {% if not identities %} +
+ You cannot give access to {{ application.name }} as you + have no identities yet. Log in via the website and create + at least one identity, then retry this process. +
+ {% else %} + + {% endif %} +{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html index 80b003b..96c0e98 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -11,6 +11,7 @@ {% include "forms/_field.html" %} {% endfor %} +