simplify developer module
This commit is contained in:
parent
ed389b9085
commit
8bcec254dc
36 changed files with 329 additions and 633 deletions
|
@ -305,7 +305,6 @@ INSTALLED_APPS += [
|
|||
"catalog.apps.CatalogConfig",
|
||||
"journal.apps.JournalConfig",
|
||||
"social.apps.SocialConfig",
|
||||
"developer.apps.DeveloperConfig",
|
||||
"takahe.apps.TakaheConfig",
|
||||
"legacy.apps.LegacyConfig",
|
||||
]
|
||||
|
@ -313,9 +312,6 @@ INSTALLED_APPS += [
|
|||
for app in env("NEODB_EXTRA_APPS"):
|
||||
INSTALLED_APPS.append(app)
|
||||
|
||||
INSTALLED_APPS += [ # we may override templates in these 3rd party apps
|
||||
"oauth2_provider",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
|
@ -325,7 +321,6 @@ MIDDLEWARE = [
|
|||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"oauth2_provider.middleware.OAuth2TokenMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"hijack.middleware.HijackUserMiddleware",
|
||||
|
@ -363,7 +358,6 @@ SESSION_COOKIE_AGE = 90 * 24 * 60 * 60 # 90 days
|
|||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"mastodon.auth.OAuth2Backend",
|
||||
"oauth2_provider.backends.OAuth2Backend",
|
||||
]
|
||||
|
||||
LOG_LEVEL = env("NEODB_LOG_LEVEL", default="DEBUG" if DEBUG else "INFO") # type:ignore
|
||||
|
|
|
@ -45,7 +45,6 @@ urlpatterns = [
|
|||
path("hijack/", include("hijack.urls")),
|
||||
path("", include("common.urls")),
|
||||
path("", include("legacy.urls")),
|
||||
path("", include("developer.urls")),
|
||||
path("", include("takahe.urls")),
|
||||
path("tz_detect/", include("tz_detect.urls")),
|
||||
path(settings.ADMIN_URL + "/", admin.site.urls),
|
||||
|
|
|
@ -13,6 +13,7 @@ from common.models import BaseJob, JobManager
|
|||
from journal.models import (
|
||||
Collection,
|
||||
Comment,
|
||||
Review,
|
||||
ShelfMember,
|
||||
TagManager,
|
||||
q_item_in_category,
|
||||
|
@ -138,21 +139,24 @@ class DiscoverGenerator(BaseJob):
|
|||
.values_list("pk", flat=True)[:40]
|
||||
)
|
||||
tags = TagManager.popular_tags(days=14)[:40]
|
||||
post_ids = set(
|
||||
Takahe.get_popular_posts(7, settings.MIN_MARKS_FOR_DISCOVER).values_list(
|
||||
"pk", flat=True
|
||||
)[:10]
|
||||
) | set(
|
||||
Takahe.get_popular_posts(28, settings.MIN_MARKS_FOR_DISCOVER).values_list(
|
||||
"pk", flat=True
|
||||
)[:20]
|
||||
)
|
||||
if len(post_ids) < 30:
|
||||
post_ids |= set(
|
||||
Comment.objects.filter(visibility=0)
|
||||
.order_by("-created_time")
|
||||
.values_list("posts", flat=True)[:2]
|
||||
post_ids = (
|
||||
set(
|
||||
Takahe.get_popular_posts(
|
||||
28, settings.MIN_MARKS_FOR_DISCOVER
|
||||
).values_list("pk", flat=True)[:20]
|
||||
)
|
||||
| set(
|
||||
Takahe.get_popular_posts(
|
||||
7, settings.MIN_MARKS_FOR_DISCOVER
|
||||
).values_list("pk", flat=True)[:10]
|
||||
)
|
||||
| set(Takahe.get_popular_posts(2, 0).values_list("pk", flat=True)[:3])
|
||||
| set(
|
||||
Review.objects.filter(visibility=0)
|
||||
.order_by("-created_time")
|
||||
.values_list("posts", flat=True)[:3]
|
||||
)
|
||||
)
|
||||
cache.set("public_gallery", gallery_list, timeout=None)
|
||||
cache.set("trends_links", trends, timeout=None)
|
||||
cache.set("featured_collections", collection_ids, timeout=None)
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
<div>{% include '_people.html' with people=item.genre role='genre' max=5 %}</div>
|
||||
<div>
|
||||
{% if item.barcode %}
|
||||
{% trans 'barcode' %}{{ item.barcode }}
|
||||
{% trans 'barcode' %}: {{ item.barcode }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
<div>{% include '_people.html' with people=item.publisher role='publisher' max=2 %}</div>
|
||||
<div>
|
||||
{% if item.official_site %}
|
||||
{% trans 'website' %}{{ item.official_site|urlizetrunc:24 }}
|
||||
{% trans 'website' %}: {{ item.official_site|urlizetrunc:24 }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -6,9 +6,9 @@ from loguru import logger
|
|||
from ninja import NinjaAPI, Schema
|
||||
from ninja.pagination import PageNumberPagination as NinjaPageNumberPagination
|
||||
from ninja.security import HttpBearer
|
||||
from oauth2_provider.oauth2_backends import OAuthLibCore
|
||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||
from oauthlib.oauth2 import Server
|
||||
|
||||
from takahe.utils import Takahe
|
||||
from users.models.apidentity import APIdentity
|
||||
|
||||
PERMITTED_WRITE_METHODS = ["PUT", "POST", "DELETE", "PATCH"]
|
||||
PERMITTED_READ_METHODS = ["GET", "HEAD", "OPTIONS"]
|
||||
|
@ -16,23 +16,38 @@ PERMITTED_READ_METHODS = ["GET", "HEAD", "OPTIONS"]
|
|||
|
||||
class OAuthAccessTokenAuth(HttpBearer):
|
||||
def authenticate(self, request, token) -> bool:
|
||||
if not token or not request.user.is_authenticated:
|
||||
logger.debug("API auth: no access token or user not authenticated")
|
||||
if not token:
|
||||
logger.debug("API auth: no access token provided")
|
||||
return False
|
||||
request_scopes = []
|
||||
tk = Takahe.get_token(token)
|
||||
if not tk:
|
||||
logger.debug("API auth: access token not found")
|
||||
return False
|
||||
request_scope = ""
|
||||
request_method = request.method
|
||||
if request_method in PERMITTED_READ_METHODS:
|
||||
request_scopes = ["read"]
|
||||
request_scope = "read"
|
||||
elif request_method in PERMITTED_WRITE_METHODS:
|
||||
request_scopes = ["write"]
|
||||
request_scope = "write"
|
||||
else:
|
||||
logger.debug("API auth: unsupported HTTP method")
|
||||
return False
|
||||
validator = OAuth2Validator()
|
||||
core = OAuthLibCore(Server(validator))
|
||||
valid, oauthlib_req = core.verify_request(request, scopes=request_scopes)
|
||||
if not valid:
|
||||
logger.debug(f"API auth: request scope {request_scopes} not verified")
|
||||
return valid
|
||||
if request_scope not in tk.scopes:
|
||||
logger.debug("API auth: scope not allowed")
|
||||
return False
|
||||
identity = APIdentity.objects.filter(pk=tk.identity_id).first()
|
||||
if not identity:
|
||||
logger.debug("API auth: identity not found")
|
||||
return False
|
||||
if identity.deleted:
|
||||
logger.debug("API auth: identity deleted")
|
||||
return False
|
||||
user = identity.user
|
||||
if not user:
|
||||
logger.debug("API auth: user not found")
|
||||
return False
|
||||
request.user = user
|
||||
return True
|
||||
|
||||
|
||||
class EmptyResult(Schema):
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<!-- <a href="/pages/rules/">{% trans 'Rules' %}</a> -->
|
||||
<!-- <a href="/pages/terms/">{% trans 'Terms' %}</a> -->
|
||||
<a href="{% url 'users:announcements' %}">{% trans 'Announcements' %}</a>
|
||||
<a href="{% url 'oauth2_provider:developer' %}">{% trans 'Developer' %}</a>
|
||||
<a href="{% url 'common:developer' %}">{% trans 'Developer' %}</a>
|
||||
<a title="{{ neodb_version }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
|
|
|
@ -33,11 +33,14 @@
|
|||
<body>
|
||||
{% include "_header.html" %}
|
||||
<main class="container">
|
||||
<h3>
|
||||
Developer Console | <a href="{% url 'oauth2_provider:list' %}">{% trans "Your applications" %}</a>
|
||||
</h3>
|
||||
<p class="empty">
|
||||
By using our APIs, you agree to our <a href="/pages/terms">Terms of Service</a>.
|
||||
<h3>Developer Console</h3>
|
||||
<p>
|
||||
<li class="empty">
|
||||
By using our APIs, you agree to our <a href="/pages/terms">Terms of Service</a>.
|
||||
</li>
|
||||
<li class="empty">
|
||||
In additional to APIs below, {{ site_name }} also supports a subset of <a href="https://docs.joinmastodon.org/client/intro/">Mastodon APIs</a>.
|
||||
</li>
|
||||
</p>
|
||||
<details {% if token %}open{% endif %}>
|
||||
<summary>
|
||||
|
@ -48,7 +51,8 @@
|
|||
<input type="text"
|
||||
readonly
|
||||
value="{{ token | default:'Once generated, token will only be shown once here, previous tokens will be revoked.' }}">
|
||||
<input type="submit" value="Generate" />
|
||||
<input type="submit"
|
||||
{% if request.user.is_authenticated %}value="Generate"{% else %}value="Login to enerate" disabled{% endif %} />
|
||||
</form>
|
||||
<p>
|
||||
Click <code>Authorize</code> button below, input your token there to invoke APIs with your account, which is required for APIs like <code>/api/me</code>
|
||||
|
@ -62,32 +66,7 @@
|
|||
<summary>
|
||||
<a>How to authorize</a>
|
||||
</summary>
|
||||
0. Create an application (you must have at least one URL included in the Redirect URIs field, e.g. <code>https://example.org/callback</code>)
|
||||
<br>
|
||||
1. Guide your user to open this URL
|
||||
<input type="text"
|
||||
value="{{ site_url }}/auth/oauth/authorize/?response_type=code&client_id=CLIENT_ID&redirect_uri=https://example.org/callback"
|
||||
readonly>
|
||||
2. Once authorizated by user, it will redirect to <code>https://example.org/callback</code> with a <code>code</code> parameter:
|
||||
<input type="text"
|
||||
value="https://example.org/callback?code=AUTH_CODE"
|
||||
readonly>
|
||||
3. Obtain access token with the following POST request:
|
||||
<textarea readonly rows="7">
|
||||
curl -X POST {{ site_url }}/auth/oauth/token/ \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "client_id=CLIENT_ID" \
|
||||
-d "client_secret=CLIENT_SECRET" \
|
||||
-d "code=AUTH_CODE" \
|
||||
-d "redirect_uri=https://example.org/callback" \
|
||||
-d "grant_type=authorization_code"</textarea>
|
||||
and access token will be returned in the response:
|
||||
<textarea readonly rows="1">{"access_token": "ACCESS_TOKEN", "expires_in": 31536000, "token_type": "Bearer", "scope": "read write", "refresh_token": "REFRESH_TOKEN"}</textarea>
|
||||
4. Use the access token to access protected endpoints like <code>/api/me</code>
|
||||
<textarea readonly rows="1">curl -H "Authorization: Bearer ACCESS_TOKEN" -X GET http://localhost:8000/api/me</textarea>
|
||||
and response will be returned accordingly:
|
||||
<textarea readonly rows="1">{"url": "https://neodb.social/users/xxx@yyy.zzz/", "external_acct": "xxx@yyy.zzz", "display_name": "XYZ", "avatar": "https://yyy.zzz/xxx.gif"}</textarea>
|
||||
more endpoints can be found in API Documentation below.
|
||||
Please check <a href="https://neodb.net/api/">NeoDB documentation</a> for details.
|
||||
</details>
|
||||
<div id="swagger-ui" data-theme="light"></div>
|
||||
<script src="{{ cdn_url }}/npm/swagger-ui-dist@5.13.0/swagger-ui-bundle.min.js"></script>
|
|
@ -8,5 +8,6 @@ urlpatterns = [
|
|||
path("home/", home, name="home"),
|
||||
path("me/", me, name="me"),
|
||||
path("nodeinfo/2.0/", nodeinfo2),
|
||||
path("developer/", console, name="developer"),
|
||||
re_path("^~neodb~(?P<uri>.+)", ap_redirect),
|
||||
]
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from boofilsic import __version__
|
||||
from users.models import User
|
||||
from takahe.utils import Takahe
|
||||
|
||||
from .api import api
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -75,3 +75,24 @@ def error_404(request, exception=None):
|
|||
|
||||
def error_500(request, exception=None):
|
||||
return render(request, "500.html", status=500, context={"exception": exception})
|
||||
|
||||
|
||||
def console(request):
|
||||
token = None
|
||||
if request.method == "POST":
|
||||
if not request.user.is_authenticated:
|
||||
return redirect(reverse("users:login"))
|
||||
app = Takahe.get_or_create_app(
|
||||
"Dev Console",
|
||||
settings.SITE_INFO["site_url"],
|
||||
"",
|
||||
owner_pk=0,
|
||||
client_id="app-00000000000-dev",
|
||||
)
|
||||
token = Takahe.refresh_token(app, request.user.identity.pk, request.user.pk)
|
||||
context = {
|
||||
"api": api,
|
||||
"token": token,
|
||||
"openapi_json_url": reverse(f"{api.urls_namespace}:openapi-json"),
|
||||
}
|
||||
return render(request, "console.html", context)
|
||||
|
|
|
@ -61,6 +61,7 @@ x-shared:
|
|||
TAKAHE_MEDIA_BACKEND: local://www/media/
|
||||
TAKAHE_MEDIA_ROOT: /www/media
|
||||
TAKAHE_USE_PROXY_HEADERS: true
|
||||
TAKAHE_ALLOW_USER_MIGRATION: true
|
||||
TAKAHE_STATOR_CONCURRENCY: ${TAKAHE_STATOR_CONCURRENCY:-4}
|
||||
TAKAHE_STATOR_CONCURRENCY_PER_MODEL: ${TAKAHE_STATOR_CONCURRENCY_PER_MODEL:-2}
|
||||
TAKAHE_VAPID_PUBLIC_KEY:
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DeveloperConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "developer"
|
|
@ -1,128 +0,0 @@
|
|||
# Generated by Django 3.2.19 on 2023-06-28 05:09
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import markdownx.models
|
||||
import oauth2_provider.generators
|
||||
import oauth2_provider.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Application",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_id,
|
||||
max_length=100,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"redirect_uris",
|
||||
models.TextField(
|
||||
blank=True, help_text="Allowed URIs list, space separated"
|
||||
),
|
||||
),
|
||||
(
|
||||
"post_logout_redirect_uris",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Allowed Post Logout URIs list, space separated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("confidential", "Confidential"),
|
||||
("public", "Public"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_grant_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("authorization-code", "Authorization code"),
|
||||
("implicit", "Implicit"),
|
||||
("password", "Resource owner password-based"),
|
||||
("client-credentials", "Client credentials"),
|
||||
("openid-hybrid", "OpenID connect hybrid"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_secret",
|
||||
oauth2_provider.models.ClientSecretField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_secret,
|
||||
help_text="Hashed on Save. Copy it now if this is a new secret.",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("skip_authorization", models.BooleanField(default=False)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"algorithm",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "No OIDC support"),
|
||||
("RS256", "RSA with SHA-2 256"),
|
||||
("HS256", "HMAC with SHA-2 256"),
|
||||
],
|
||||
default="",
|
||||
max_length=5,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
max_length=255,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="minimum two characters, words and -_. only, no special characters",
|
||||
regex="^\\w[\\w_\\-. ]*\\w$",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
markdownx.models.MarkdownxField(blank=True, default=""),
|
||||
),
|
||||
("url", models.URLField(blank=True, null=True)),
|
||||
("is_official", models.BooleanField(default=False)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="developer_application",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,26 +0,0 @@
|
|||
# Generated by Django 4.2.3 on 2023-07-06 22:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("developer", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="application",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,19 +0,0 @@
|
|||
# Generated by Django 4.2.3 on 2023-07-13 04:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("developer", "0002_alter_application_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="application",
|
||||
name="redirect_uris",
|
||||
field=models.TextField(
|
||||
help_text="Allowed URIs list, space separated, at least one URI is required"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,33 +0,0 @@
|
|||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from markdownx.models import MarkdownxField
|
||||
from oauth2_provider.models import AbstractApplication
|
||||
|
||||
from journal.models.renderers import render_md
|
||||
|
||||
|
||||
class Application(AbstractApplication):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^\w[\w_\-. ]*\w$",
|
||||
message=_(
|
||||
"minimum two characters, words and -_. only, no special characters"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
description = MarkdownxField(default="", blank=True)
|
||||
url = models.URLField(null=True, blank=True)
|
||||
is_official = models.BooleanField(default=False)
|
||||
unique_together = [["user", "name"]]
|
||||
redirect_uris = models.TextField(
|
||||
blank=False,
|
||||
help_text=_("Allowed URIs list, space separated, at least one URI is required"),
|
||||
)
|
||||
|
||||
def description_html(self):
|
||||
return render_md(self.description)
|
|
@ -1,45 +0,0 @@
|
|||
{% extends "oauth2_provider/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<div class="block-center">
|
||||
<h3 class="block-center-heading">{{ application.name }}</h3>
|
||||
<ul class="unstyled">
|
||||
<li>
|
||||
<p>
|
||||
<b>{% trans "Client ID" %}</b>
|
||||
</p>
|
||||
<input class="input-block-level"
|
||||
type="text"
|
||||
value="{{ application.client_id }}"
|
||||
readonly>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<b>{% trans "URL" %}</b>
|
||||
</p>
|
||||
<p>{{ application.url | default:"" | urlize }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<b>{% trans "Description" %}</b>
|
||||
</p>
|
||||
<p>{{ application.description_html|safe }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<b>{% trans "Redirect Uris" %}</b>
|
||||
{% if not application.redirect_uris %}WARNING: no redirect uris have been set, authorization may not work.{% endif %}
|
||||
</p>
|
||||
<textarea readonly>{{ application.redirect_uris }}</textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<div class="btn-toolbar">
|
||||
<a class="btn" href="{% url "oauth2_provider:list" %}">{% trans "Go Back" %}</a>
|
||||
<a class="btn btn-primary"
|
||||
href="{% url "oauth2_provider:update" application.id %}">{% trans "Edit" %}</a>
|
||||
<a class="btn btn-danger"
|
||||
href="{% url "oauth2_provider:delete" application.id %}">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
|
@ -1,39 +0,0 @@
|
|||
{% extends "oauth2_provider/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<form method="post"
|
||||
action="{% block app-form-action-url %}{% url 'oauth2_provider:update' application.id %}{% endblock app-form-action-url %}">
|
||||
<h3>
|
||||
{% block app-form-title %}
|
||||
{% trans "Edit application" %} {{ application.name }}
|
||||
{% endblock app-form-title %}
|
||||
</h3>
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
{% for field in form %}
|
||||
<label {% if field.errors %}aria-invalid="true"{% endif %}>
|
||||
{{ field.label }}
|
||||
{% if field.name == 'client_secret' %}
|
||||
<small><b>(please save it properly as it will NOT be editable or shown again)</b></small>
|
||||
{% elif field.name == 'description' %}
|
||||
<small>(markdown syntax supported)</small>
|
||||
{% endif %}
|
||||
{{ field }}
|
||||
{% for error in field.errors %}<small>{{ error }}</small>{% endfor %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<div class="control-group {% if form.non_field_errors %}error{% endif %}">
|
||||
{% for error in form.non_field_errors %}<span class="help-inline">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="controls">
|
||||
<a class="btn"
|
||||
href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.id %}{% endblock app-form-back-url %}">
|
||||
{% trans "Go Back" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,27 +0,0 @@
|
|||
{% extends "oauth2_provider/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<h3>
|
||||
<a href="{% url 'oauth2_provider:developer' %}">Developer Console</a> | {% trans "Your applications" %}
|
||||
</h3>
|
||||
<div class="block-center">
|
||||
{% if not request.user.mastodon_acct %}
|
||||
<p>
|
||||
Please <a href="{% url 'users:info' %}">connect to a Fediverse identity</a> before creating an application.
|
||||
</p>
|
||||
{% elif applications %}
|
||||
<ul>
|
||||
{% for application in applications %}
|
||||
<li>
|
||||
<a href="{{ application.get_absolute_url }}">{{ application.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a class="btn btn-success" href="{% url "oauth2_provider:register" %}">{% trans "New Application" %}</a>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "No applications defined" %}. <a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
|
@ -1,41 +0,0 @@
|
|||
{% extends "oauth2_provider/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<article>
|
||||
{% if not error %}
|
||||
<header>
|
||||
<h4>授权 {{ application.name }} 访问你的帐户吗?</h4>
|
||||
</header>
|
||||
<form id="authorizationForm" method="post">
|
||||
{% csrf_token %}
|
||||
{% if not application.is_official %}
|
||||
<p>
|
||||
<b>{{ application.name }}</b> 是由 <a href="{{ application.user.identity.url }}">@{{ application.user.identity.handle }}</a> 创建和维护的应用程序。
|
||||
{{ site_name }}无法保证其安全性和有效性,请自行验证确认后再授权。
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if application.url %}应用网址: {{ application.url | urlize }}{% endif %}
|
||||
<p>{{ application.description_html|safe }}</p>
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden %}{{ field }}{% endif %}
|
||||
{% endfor %}
|
||||
<p>授权后这个应用将能读写你帐户的全部数据。</p>
|
||||
{% comment %} <p>{% trans "Application requires the following permissions" %}</p>
|
||||
<ul>
|
||||
{% for scope in scopes_descriptions %}<li>{{ scope }}</li>{% endfor %}
|
||||
</ul> {% endcomment %}
|
||||
{{ form.errors }}
|
||||
{{ form.non_field_errors }}
|
||||
<footer>
|
||||
<div class="grid">
|
||||
<input type="submit" class="primary" name="allow" value="确认授权" />
|
||||
<input type="submit" class="secondary" value="取消" />
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
{% else %}
|
||||
<h2>Error: {{ error.error }}</h2>
|
||||
<p>{{ error.description }}</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endblock %}
|
|
@ -1,25 +0,0 @@
|
|||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% block title %}
|
||||
{% endblock title %}
|
||||
<title>{{ site_name }}</title>
|
||||
{% include "common_libs.html" %}
|
||||
<style>
|
||||
.controls input {
|
||||
width: unset;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
<main class="container">
|
||||
{% block content %}
|
||||
{% endblock content %}
|
||||
</main>
|
||||
{% include "_footer.html" %}
|
||||
</body>
|
||||
</html>
|
|
@ -1,3 +0,0 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
|
@ -1,69 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import include, path, re_path
|
||||
from oauth2_provider import views as oauth2_views
|
||||
from oauth2_provider.views import oidc as oidc_views
|
||||
|
||||
from .views import *
|
||||
|
||||
_urlpatterns = [
|
||||
re_path(
|
||||
r"^auth/oauth/authorize/$",
|
||||
oauth2_views.AuthorizationView.as_view(),
|
||||
name="authorize",
|
||||
),
|
||||
re_path(r"^oauth/token/$", oauth2_views.TokenView.as_view(), name="token"),
|
||||
re_path(
|
||||
r"^auth/oauth/revoke_token/$",
|
||||
oauth2_views.RevokeTokenView.as_view(),
|
||||
name="revoke-token",
|
||||
),
|
||||
re_path(
|
||||
r"^auth/oauth/introspect/$",
|
||||
oauth2_views.IntrospectTokenView.as_view(),
|
||||
name="introspect",
|
||||
),
|
||||
re_path(
|
||||
r"^auth/oauth/authorized_tokens/$",
|
||||
oauth2_views.AuthorizedTokensListView.as_view(),
|
||||
name="authorized-token-list",
|
||||
),
|
||||
re_path(
|
||||
r"^auth/oauth/authorized_tokens/(?P<pk>[\w-]+)/delete/$",
|
||||
oauth2_views.AuthorizedTokenDeleteView.as_view(),
|
||||
name="authorized-token-delete",
|
||||
),
|
||||
]
|
||||
|
||||
_urlpatterns += [
|
||||
path("developer/", console, name="developer"),
|
||||
re_path(
|
||||
r"^developer/applications/$",
|
||||
oauth2_views.ApplicationList.as_view(),
|
||||
name="list",
|
||||
),
|
||||
re_path(
|
||||
r"^developer/applications/register/$",
|
||||
ApplicationRegistration.as_view(),
|
||||
name="register",
|
||||
),
|
||||
re_path(
|
||||
r"^developer/applications/(?P<pk>[\w-]+)/$",
|
||||
oauth2_views.ApplicationDetail.as_view(),
|
||||
name="detail",
|
||||
),
|
||||
re_path(
|
||||
r"^developer/applications/(?P<pk>[\w-]+)/delete/$",
|
||||
oauth2_views.ApplicationDelete.as_view(),
|
||||
name="delete",
|
||||
),
|
||||
re_path(
|
||||
r"^developer/applications/(?P<pk>[\w-]+)/update/$",
|
||||
ApplicationUpdate.as_view(),
|
||||
name="update",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
path("", include((_urlpatterns, "oauth2_provider"))),
|
||||
]
|
|
@ -1,90 +0,0 @@
|
|||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.forms.models import modelform_factory
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from loguru import logger
|
||||
from oauth2_provider.forms import AllowForm
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oauth2_provider.models import AccessToken, RefreshToken, get_application_model
|
||||
from oauth2_provider.settings import oauth2_settings
|
||||
from oauth2_provider.views import ApplicationRegistration as BaseApplicationRegistration
|
||||
from oauth2_provider.views import ApplicationUpdate as BaseApplicationUpdate
|
||||
from oauth2_provider.views.base import AuthorizationView as BaseAuthorizationView
|
||||
from oauthlib.common import generate_token
|
||||
|
||||
from common.api import api
|
||||
|
||||
from .models import Application
|
||||
|
||||
|
||||
class ApplicationRegistration(BaseApplicationRegistration):
|
||||
def get_form_class(self):
|
||||
return modelform_factory(
|
||||
Application,
|
||||
fields=(
|
||||
"name",
|
||||
"url",
|
||||
"description",
|
||||
"client_secret",
|
||||
"redirect_uris",
|
||||
# "post_logout_redirect_uris",
|
||||
),
|
||||
)
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.user = self.request.user
|
||||
if not form.instance.id:
|
||||
form.instance.client_id = generate_client_id()
|
||||
# form.instance.client_secret = generate_client_secret()
|
||||
form.instance.client_type = Application.CLIENT_CONFIDENTIAL
|
||||
form.instance.authorization_grant_type = (
|
||||
Application.GRANT_AUTHORIZATION_CODE
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class ApplicationUpdate(BaseApplicationUpdate):
|
||||
def get_form_class(self):
|
||||
return modelform_factory(
|
||||
get_application_model(), # type:ignore
|
||||
fields=(
|
||||
"name",
|
||||
"url",
|
||||
"description",
|
||||
# "client_secret",
|
||||
"redirect_uris",
|
||||
# "post_logout_redirect_uris",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def console(request):
|
||||
token = None
|
||||
if request.method == "POST":
|
||||
user = request.user
|
||||
app = Application.objects.filter(
|
||||
client_id=settings.DEVELOPER_CONSOLE_APPLICATION_CLIENT_ID
|
||||
).first()
|
||||
if app:
|
||||
for token in AccessToken.objects.filter(user=user, application=app):
|
||||
token.revoke()
|
||||
token = generate_token()
|
||||
AccessToken.objects.create(
|
||||
user=user,
|
||||
application=app,
|
||||
scope="read write",
|
||||
expires=timezone.now() + relativedelta(days=365),
|
||||
token=token,
|
||||
)
|
||||
else:
|
||||
token = "Configuration error, contact admin"
|
||||
context = {
|
||||
"api": api,
|
||||
"token": token,
|
||||
"openapi_json_url": reverse(f"{api.urls_namespace}:openapi-json"),
|
||||
}
|
||||
return render(request, "console.html", context)
|
79
docs/api.md
Normal file
79
docs/api.md
Normal file
|
@ -0,0 +1,79 @@
|
|||
# API
|
||||
|
||||
NeoDB has a set of API endpoints mapping to its functions like marking a book or listing collections, they can be found in swagger based API documentation at `/developer/` of your running instance, [a version of it](https://neodb.social/developer/) is available on our flagship instance.
|
||||
|
||||
NeoDB also supports a subset of Mastodon API, details can be found in [Mastodon API documentation](https://docs.joinmastodon.org/api/).
|
||||
|
||||
Both set of APIs can be accessed by the same access token.
|
||||
|
||||
## How to authorize
|
||||
|
||||
0. Create an application
|
||||
|
||||
you must have at least one URL included in the Redirect URIs field, e.g. `https://example.org/callback`, or use `urn:ietf:wg:oauth:2.0:oob` if you don't have a callback URL.
|
||||
|
||||
```
|
||||
curl https://neodb.social/api/v1/apps \
|
||||
-d client_name=MyApp \
|
||||
-d redirect_uris=https://example.org/callback \
|
||||
-d website=https://my.site
|
||||
```
|
||||
|
||||
and save of the `client_id` and `client_secret` returned in the response:
|
||||
|
||||
```
|
||||
{
|
||||
"client_id": "CLIENT_ID",
|
||||
"client_secret": "CLIENT_SECRET",
|
||||
"name": "MyApp",
|
||||
"redirect_uri": "https://example.org/callback",
|
||||
"vapid_key": "PUSH_KEY",
|
||||
"website": "https://my.site"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
1. Guide your user to open this URL
|
||||
|
||||
```
|
||||
https://neodb.social/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=https://example.org/callback&scope=read+write
|
||||
```
|
||||
|
||||
2. Once authorizated by user, it will redirect to `https://example.org/callback` with a `code` parameter:
|
||||
|
||||
```
|
||||
https://example.org/callback?code=AUTH_CODE
|
||||
```
|
||||
|
||||
3. Obtain access token with the following POST request:
|
||||
|
||||
```
|
||||
curl https://neodb.social/oauth/token \
|
||||
-d "client_id=CLIENT_ID" \
|
||||
-d "client_secret=CLIENT_SECRET" \
|
||||
-d "code=AUTH_CODE" \
|
||||
-d "redirect_uri=https://example.org/callback" \
|
||||
-d "grant_type=authorization_code"
|
||||
```
|
||||
|
||||
and access token will be returned in the response:
|
||||
|
||||
```
|
||||
{
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"token_type": "Bearer",
|
||||
"scope": "read write"
|
||||
}
|
||||
```
|
||||
|
||||
4. Use the access token to access protected endpoints like `/api/me`
|
||||
|
||||
```
|
||||
curl -H "Authorization: Bearer ACCESS_TOKEN" -X GET https://neodb.social/api/me
|
||||
```
|
||||
|
||||
and response will be returned accordingly:
|
||||
|
||||
```
|
||||
{"url": "https://neodb.social/users/xxx/", "external_acct": "xxx@yyy.zzz", "display_name": "XYZ", "avatar": "https://yyy.zzz/xxx.gif"}
|
||||
```
|
|
@ -22,8 +22,7 @@ All instances interact with each other in the Fediverse via ActivityPub, allowin
|
|||
|
||||
## API and Development
|
||||
|
||||
- NeoDB offers an API to manage user collections, with swagger-based documentation available in the [NeoDB API Developer Console](https://neodb.social/developer/).
|
||||
- [Mastodon client compatible API](https://docs.joinmastodon.org/client/) is also available
|
||||
- NeoDB offers [APIs to manage user collections](api.md), and [Mastodon client compatible API](https://docs.joinmastodon.org/client/) to manage user posts.
|
||||
- For those interested in developing for NeoDB, please refer to the [development](development.md) section for basic instructions to get started.
|
||||
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden"
|
||||
{% if k == "wishlist" or k == "dropped" %} _="on click add .outline to .status-selector then remove .outline from me then set value of #mark-status to '{{ k }}' then add .hidden to .rating-editor then halt" {% else %} _="on click add .outline to .status-selector then remove .outline from me then set value of #mark-status to '{{ k }}' then remove .hidden from .rating-editor then halt" {% endif %}
|
||||
{% if k == "wishlist" %} _="on click add .outline to .status-selector then remove .outline from me then set value of #mark-status to '{{ k }}' then add .hidden to .rating-editor then halt" {% else %} _="on click add .outline to .status-selector then remove .outline from me then set value of #mark-status to '{{ k }}' then remove .hidden from .rating-editor then halt" {% endif %}
|
||||
{% if shelf_type == k %}class="status-selector"{% else %}class="status-selector outline"{% endif %}>
|
||||
{{ v }}
|
||||
</button>
|
||||
|
|
|
@ -11,6 +11,7 @@ nav:
|
|||
- configuration.md
|
||||
- troubleshooting.md
|
||||
- development.md
|
||||
- api.md
|
||||
- origin.md
|
||||
theme:
|
||||
logo: assets/logo.svg
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 1522364d4fd399ba2560656e7e714ee7cea7a905
|
||||
Subproject commit 01cacb7dce19f7fadcda7e629b78b2752d74eece
|
110
takahe/models.py
110
takahe/models.py
|
@ -2103,3 +2103,113 @@ class Announcement(models.Model):
|
|||
from journal.models import render_md
|
||||
|
||||
return mark_safe(render_md(self.text))
|
||||
|
||||
|
||||
class Application(models.Model):
|
||||
"""
|
||||
OAuth applications
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "api_application"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Authorization(models.Model):
|
||||
"""
|
||||
An authorization code as part of the OAuth flow
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "api_authorization"
|
||||
|
||||
application = models.ForeignKey(
|
||||
"takahe.Application",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="authorizations",
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
"takahe.User",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="authorizations",
|
||||
)
|
||||
|
||||
identity = models.ForeignKey(
|
||||
"takahe.Identity",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="authorizations",
|
||||
)
|
||||
|
||||
code = models.CharField(max_length=128, blank=True, null=True, unique=True)
|
||||
token = models.OneToOneField(
|
||||
"takahe.Token",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
scopes = models.JSONField()
|
||||
redirect_uri = models.TextField(blank=True, null=True)
|
||||
valid_for_seconds = models.IntegerField(default=60)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class Token(models.Model):
|
||||
"""
|
||||
An (access) token to call the API with.
|
||||
|
||||
Can be either tied to a user, or app-level only.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "api_token"
|
||||
|
||||
identity_id: int | None
|
||||
application = models.ForeignKey(
|
||||
"takahe.Application",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="tokens",
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
"takahe.User",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="tokens",
|
||||
)
|
||||
|
||||
identity = models.ForeignKey(
|
||||
"takahe.Identity",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="tokens",
|
||||
)
|
||||
|
||||
token = models.CharField(max_length=500, unique=True)
|
||||
scopes = models.JSONField()
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
revoked = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
# push_subscription: "PushSubscription"
|
||||
|
|
|
@ -6,6 +6,7 @@ import blurhash
|
|||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.images import ImageFile
|
||||
from django.core.signing import b62_encode
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
@ -1010,3 +1011,50 @@ class Takahe:
|
|||
if featured:
|
||||
identity.fanout("tag_unfeatured", subject_hashtag_id=hashtag)
|
||||
featured.delete()
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_app(
|
||||
name: str,
|
||||
website: str,
|
||||
redirect_uris: str,
|
||||
owner_pk: int,
|
||||
scopes: str = "read write follow",
|
||||
client_id: str | None = None,
|
||||
):
|
||||
client_id = client_id or (
|
||||
"app-" + b62_encode(owner_pk).zfill(11) + "-" + secrets.token_urlsafe(16)
|
||||
)
|
||||
client_secret = secrets.token_urlsafe(40)
|
||||
return Application.objects.get_or_create(
|
||||
client_id=client_id,
|
||||
defaults={
|
||||
"name": name,
|
||||
"website": website,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": redirect_uris,
|
||||
"scopes": scopes,
|
||||
},
|
||||
)[0]
|
||||
|
||||
@staticmethod
|
||||
def get_apps(owner_pk: int):
|
||||
return Application.objects.filter(
|
||||
name__startswith="app-" + b62_encode(owner_pk).zfill(11)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def refresh_token(app: Application, owner_pk: int, user_pk) -> str:
|
||||
tk = Token.objects.filter(application=app, identity_id=owner_pk).first()
|
||||
if tk:
|
||||
tk.delete()
|
||||
return Token.objects.create(
|
||||
application=app,
|
||||
identity_id=owner_pk,
|
||||
user_id=user_pk,
|
||||
scopes=["read", "write"],
|
||||
token=secrets.token_urlsafe(43),
|
||||
).token
|
||||
|
||||
@staticmethod
|
||||
def get_token(token: str) -> Token | None:
|
||||
return Token.objects.filter(token=token).first()
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from ninja import Schema
|
||||
from ninja.security import django_auth
|
||||
from oauth2_provider.decorators import protected_resource
|
||||
|
||||
from common.api import *
|
||||
|
||||
|
|
|
@ -214,7 +214,7 @@
|
|||
<details>
|
||||
<summary>{% trans 'Applications' %}</summary>
|
||||
<p>
|
||||
<a href="{% url 'oauth2_provider:authorized-token-list' %}">{% trans "View authorized applications" %}</a>
|
||||
<a href="/@{{ request.user.identity.handle }}/settings/tokens/">{% trans "View authorized applications" %}</a>
|
||||
</p>
|
||||
</details>
|
||||
</article>
|
||||
|
|
Loading…
Add table
Reference in a new issue