simplify developer module

This commit is contained in:
Your Name 2024-06-10 17:28:20 -04:00 committed by Henri Dickson
parent ed389b9085
commit 8bcec254dc
36 changed files with 329 additions and 633 deletions

View file

@ -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

View file

@ -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),

View file

@ -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)

View file

@ -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>

View file

@ -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 %}

View file

@ -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):

View file

@ -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"

View file

@ -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&amp;client_id=CLIENT_ID&amp;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>

View file

@ -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),
]

View file

@ -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)

View file

@ -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:

View file

View file

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class DeveloperConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "developer"

View file

@ -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,
},
),
]

View file

@ -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,
),
),
]

View file

@ -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"
),
),
]

View file

@ -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)

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View file

@ -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"))),
]

View file

@ -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
View 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"}
```

View file

@ -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.

View file

@ -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>

View file

@ -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

View file

@ -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"

View file

@ -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()

View file

@ -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 *

View file

@ -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>