diff options
Diffstat (limited to 'api')
| -rw-r--r-- | api/__init__.py | 0 | ||||
| -rw-r--r-- | api/admin.py | 13 | ||||
| -rw-r--r-- | api/apps.py | 6 | ||||
| -rw-r--r-- | api/migrations/0001_initial.py | 87 | ||||
| -rw-r--r-- | api/migrations/__init__.py | 0 | ||||
| -rw-r--r-- | api/models/__init__.py | 2 | ||||
| -rw-r--r-- | api/models/application.py | 19 | ||||
| -rw-r--r-- | api/models/token.py | 39 | ||||
| -rw-r--r-- | api/parser.py | 20 | ||||
| -rw-r--r-- | api/views/__init__.py | 3 | ||||
| -rw-r--r-- | api/views/apps.py | 37 | ||||
| -rw-r--r-- | api/views/base.py | 5 | ||||
| -rw-r--r-- | api/views/instance.py | 56 | ||||
| -rw-r--r-- | api/views/oauth.py | 105 | 
14 files changed, 392 insertions, 0 deletions
diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/api/__init__.py 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 --- /dev/null +++ b/api/migrations/__init__.py 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  | 
