diff --git a/api/models/application.py b/api/models/application.py index 89bea5f..4a17a60 100644 --- a/api/models/application.py +++ b/api/models/application.py @@ -1,3 +1,5 @@ +import secrets + from django.db import models @@ -17,3 +19,23 @@ class Application(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + + @classmethod + def create( + cls, + client_name: str, + redirect_uris: str, + website: str | None, + scopes: str | None = None, + ): + client_id = "tk-" + secrets.token_urlsafe(16) + client_secret = secrets.token_urlsafe(40) + + return cls.objects.create( + name=client_name, + website=website, + client_id=client_id, + client_secret=client_secret, + redirect_uris=redirect_uris, + scopes=scopes or "read", + ) diff --git a/api/models/token.py b/api/models/token.py index d070a01..fc1f2ff 100644 --- a/api/models/token.py +++ b/api/models/token.py @@ -1,3 +1,4 @@ +import urlman from django.db import models @@ -37,6 +38,9 @@ class Token(models.Model): updated = models.DateTimeField(auto_now=True) revoked = models.DateTimeField(blank=True, null=True) + class urls(urlman.Urls): + edit = "/@{self.identity.handle}/settings/tokens/{self.id}/" + def has_scope(self, scope: str): """ Returns if this token has the given scope. diff --git a/api/views/apps.py b/api/views/apps.py index 758aa49..e20038d 100644 --- a/api/views/apps.py +++ b/api/views/apps.py @@ -1,5 +1,3 @@ -import secrets - from hatchway import QueryOrBody, api_view from .. import schemas @@ -14,14 +12,10 @@ def add_app( scopes: QueryOrBody[None | str] = None, website: QueryOrBody[None | str] = None, ) -> schemas.Application: - client_id = "tk-" + secrets.token_urlsafe(16) - client_secret = secrets.token_urlsafe(40) - application = Application.objects.create( - name=client_name, + application = Application.create( + client_name=client_name, website=website, - client_id=client_id, - client_secret=client_secret, redirect_uris=redirect_uris, - scopes=scopes or "read", + scopes=scopes, ) return schemas.Application.from_orm(application) diff --git a/core/models/config.py b/core/models/config.py index 674dc84..4f88365 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -292,6 +292,6 @@ class Config(models.Model): class DomainOptions(pydantic.BaseModel): site_name: str = "" - site_icon: UploadedImage | None = None # type: ignore + site_icon: UploadedImage | None = None hide_login: bool = False custom_css: str = "" diff --git a/static/css/style.css b/static/css/style.css index fe761c8..aad6a20 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -278,6 +278,7 @@ header menu a small { nav { padding: 10px 0px 20px 5px; + border-radius: 5px; } nav hr { @@ -377,7 +378,6 @@ nav .identity-banner a:hover { .settings nav { width: var(--width-sidebar-medium); background: var(--color-bg-menu); - border-radius: 0 0 5px 0; } .settings nav h2 { @@ -1095,6 +1095,25 @@ blockquote { border-left: 2px solid var(--color-bg-menu); } +.secret .label { + background-color: var(--color-bg-menu); + padding: 3px 7px; + border-radius: 3px; + cursor: pointer; +} + +.secret.visible .label { + display: none; +} + +.secret .value { + display: none; +} + +.secret.visible .value { + display: inline; +} + /* Logged out homepage */ @@ -1301,9 +1320,10 @@ table.metadata td .emoji { min-width: 16px; } -/* Announcements */ +/* Announcements/Flash messages */ -.announcement { +.announcement, +.message { background-color: var(--color-highlight); border-radius: 5px; margin: 10px 0 0 0; @@ -1311,7 +1331,12 @@ table.metadata td .emoji { position: relative; } -.announcement .dismiss { +.message { + background-color: var(--color-bg-menu); +} + +.announcement .dismiss, +.message .dismiss { position: absolute; top: 5px; right: 10px; diff --git a/stator/runner.py b/stator/runner.py index 5525be7..758d09b 100644 --- a/stator/runner.py +++ b/stator/runner.py @@ -5,6 +5,7 @@ import signal import time import traceback import uuid +from collections.abc import Callable from asgiref.sync import async_to_sync, sync_to_async from django.conf import settings @@ -21,7 +22,7 @@ class LoopingTask: copy running at a time. """ - def __init__(self, callable): + def __init__(self, callable: Callable): self.callable = callable self.task: asyncio.Task | None = None diff --git a/takahe/urls.py b/takahe/urls.py index 46c7042..2637f7e 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -2,25 +2,12 @@ from django.conf import settings as djsettings from django.contrib import admin as djadmin from django.urls import include, path, re_path -from activities.views import ( - compose, - debug, - posts, - timelines, -) +from activities.views import compose, debug, posts, timelines from api.views import oauth from core import views as core from mediaproxy import views as mediaproxy from stator import views as stator -from users.views import ( - activitypub, - admin, - announcements, - auth, - identity, - settings, -) -from users.views.settings import follows +from users.views import activitypub, admin, announcements, auth, identity, settings urlpatterns = [ path("", core.homepage), @@ -78,6 +65,21 @@ urlpatterns = [ settings.CsvFollowers.as_view(), name="settings_export_followers_csv", ), + path( + "@/settings/tokens/", + settings.TokensRoot.as_view(), + name="settings_tokens", + ), + path( + "@/settings/tokens/create/", + settings.TokenCreate.as_view(), + name="settings_token_create", + ), + path( + "@/settings/tokens//", + settings.TokenEdit.as_view(), + name="settings_token_edit", + ), path( "admin/", admin.AdminRoot.as_view(), diff --git a/templates/_announcements.html b/templates/_announcements.html index fd60116..0201564 100644 --- a/templates/_announcements.html +++ b/templates/_announcements.html @@ -4,3 +4,13 @@ {{ announcement.html }} {% endfor %} +{% if messages %} +
    + {% for message in messages %} +
    + + {{ message }} +
    + {% endfor %} +
+{% endif %} diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index 317ad45..d776a95 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -14,6 +14,10 @@ Import/Export + + + Authorized Apps +

Tools

diff --git a/templates/settings/token_create.html b/templates/settings/token_create.html new file mode 100644 index 0000000..fcd2d20 --- /dev/null +++ b/templates/settings/token_create.html @@ -0,0 +1,27 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Add App{% endblock %} + +{% block settings_content %} +
+

Create Personal Token

+

+ This lets you create a personal application token that you can + use to talk to the API. +

+

+ Do not create a token if someone else tells you to - it can let + them take over your account! +

+ {% csrf_token %} +
+ App Details + {% include "forms/_field.html" with field=form.name %} + {% include "forms/_field.html" with field=form.scope %} +
+
+ Back + +
+
+{% endblock %} diff --git a/templates/settings/token_edit.html b/templates/settings/token_edit.html new file mode 100644 index 0000000..33b614f --- /dev/null +++ b/templates/settings/token_edit.html @@ -0,0 +1,49 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Token{% endblock %} + +{% block settings_content %} +

{{ token.application.name }}

+
+ {% csrf_token %} +
+ Application + + + + + + {% if token.application.website %} + + + + + {% endif %} + + + + + +
+ +
+ Token + + + + + + +
+ +
+ Back + +
+
+{% endblock %} diff --git a/templates/settings/tokens.html b/templates/settings/tokens.html new file mode 100644 index 0000000..4c65815 --- /dev/null +++ b/templates/settings/tokens.html @@ -0,0 +1,30 @@ +{% extends "settings/base.html" %} +{% load activity_tags %} + +{% block subtitle %}Apps{% endblock %} + +{% block settings_content %} + + + {% for token in tokens %} + + + + + + {% empty %} + + {% endfor %} +
+ + + + + {{ token.application.name }} + + Created {{ token.created|timedeltashort }} ago +
You have no apps authorized against this identity.
+{% endblock %} diff --git a/users/views/settings/__init__.py b/users/views/settings/__init__.py index 80f278a..735f682 100644 --- a/users/views/settings/__init__.py +++ b/users/views/settings/__init__.py @@ -1,19 +1,20 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views.generic import View -from django.shortcuts import redirect -from django.contrib.auth.decorators import login_required +from users.views.settings.follows import FollowsPage # noqa from users.views.settings.import_export import ( # noqa CsvFollowers, CsvFollowing, ImportExportPage, ) +from users.views.settings.interface import InterfacePage # noqa from users.views.settings.posting import PostingPage # noqa from users.views.settings.profile import ProfilePage # noqa from users.views.settings.security import SecurityPage # noqa from users.views.settings.settings_page import SettingsPage # noqa -from users.views.settings.follows import FollowsPage # noqa -from users.views.settings.interface import InterfacePage # noqa +from users.views.settings.tokens import TokenCreate, TokenEdit, TokensRoot # noqa @method_decorator(login_required, name="dispatch") diff --git a/users/views/settings/tokens.py b/users/views/settings/tokens.py new file mode 100644 index 0000000..4164203 --- /dev/null +++ b/users/views/settings/tokens.py @@ -0,0 +1,76 @@ +import secrets + +from django import forms +from django.contrib import messages +from django.shortcuts import redirect +from django.views.generic import DetailView, FormView +from django.views.generic.list import ListView + +from api.models.application import Application +from api.models.token import Token +from users.views.base import IdentityViewMixin + + +class TokensRoot(IdentityViewMixin, ListView): + """ + Shows a listing of tokens the user has authorized + """ + + template_name = "settings/tokens.html" + extra_context = {"section": "tokens"} + context_object_name = "tokens" + + def get_queryset(self): + return Token.objects.filter( + user=self.request.user, + identity=self.identity, + ).prefetch_related("application") + + +class TokenCreate(IdentityViewMixin, FormView): + """ + Allows the user to create a new app and token just for themselves. + """ + + template_name = "settings/token_create.html" + + class form_class(forms.Form): + name = forms.CharField(help_text="Identifies this app in your app list") + scope = forms.ChoiceField( + choices=(("read", "Read-only access"), ("write", "Full access")), + help_text="What should this app be able to do with your account?", + ) + + def form_valid(self, form): + scopes = "read write push" if form.cleaned_data["scope"] == "write" else "read" + application = Application.create( + client_name=form.cleaned_data["name"], + website=None, + redirect_uris="urn:ietf:wg:oauth:2.0:oob", + scopes=scopes, + ) + token = Token.objects.create( + application=application, + user=self.request.user, + identity=self.identity, + token=secrets.token_urlsafe(43), + scopes=scopes, + ) + return redirect("settings_token_edit", handle=self.identity.handle, pk=token.pk) + + +class TokenEdit(IdentityViewMixin, DetailView): + + template_name = "settings/token_edit.html" + extra_context = {"section": "tokens"} + + def get_queryset(self): + return self.identity.tokens + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + messages.success( + request, f"{self.object.application.name}'s access has been removed." + ) + self.object.delete() + return redirect("settings_tokens", handle=self.identity.handle)