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", "catalog.apps.CatalogConfig",
"journal.apps.JournalConfig", "journal.apps.JournalConfig",
"social.apps.SocialConfig", "social.apps.SocialConfig",
"developer.apps.DeveloperConfig",
"takahe.apps.TakaheConfig", "takahe.apps.TakaheConfig",
"legacy.apps.LegacyConfig", "legacy.apps.LegacyConfig",
] ]
@ -313,9 +312,6 @@ INSTALLED_APPS += [
for app in env("NEODB_EXTRA_APPS"): for app in env("NEODB_EXTRA_APPS"):
INSTALLED_APPS.append(app) INSTALLED_APPS.append(app)
INSTALLED_APPS += [ # we may override templates in these 3rd party apps
"oauth2_provider",
]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
@ -325,7 +321,6 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"oauth2_provider.middleware.OAuth2TokenMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"hijack.middleware.HijackUserMiddleware", "hijack.middleware.HijackUserMiddleware",
@ -363,7 +358,6 @@ SESSION_COOKIE_AGE = 90 * 24 * 60 * 60 # 90 days
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"mastodon.auth.OAuth2Backend", "mastodon.auth.OAuth2Backend",
"oauth2_provider.backends.OAuth2Backend",
] ]
LOG_LEVEL = env("NEODB_LOG_LEVEL", default="DEBUG" if DEBUG else "INFO") # type:ignore 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("hijack/", include("hijack.urls")),
path("", include("common.urls")), path("", include("common.urls")),
path("", include("legacy.urls")), path("", include("legacy.urls")),
path("", include("developer.urls")),
path("", include("takahe.urls")), path("", include("takahe.urls")),
path("tz_detect/", include("tz_detect.urls")), path("tz_detect/", include("tz_detect.urls")),
path(settings.ADMIN_URL + "/", admin.site.urls), path(settings.ADMIN_URL + "/", admin.site.urls),

View file

@ -13,6 +13,7 @@ from common.models import BaseJob, JobManager
from journal.models import ( from journal.models import (
Collection, Collection,
Comment, Comment,
Review,
ShelfMember, ShelfMember,
TagManager, TagManager,
q_item_in_category, q_item_in_category,
@ -138,21 +139,24 @@ class DiscoverGenerator(BaseJob):
.values_list("pk", flat=True)[:40] .values_list("pk", flat=True)[:40]
) )
tags = TagManager.popular_tags(days=14)[:40] tags = TagManager.popular_tags(days=14)[:40]
post_ids = set( post_ids = (
Takahe.get_popular_posts(7, settings.MIN_MARKS_FOR_DISCOVER).values_list( set(
"pk", flat=True Takahe.get_popular_posts(
)[:10] 28, settings.MIN_MARKS_FOR_DISCOVER
) | set( ).values_list("pk", flat=True)[:20]
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]
) )
| 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("public_gallery", gallery_list, timeout=None)
cache.set("trends_links", trends, timeout=None) cache.set("trends_links", trends, timeout=None)
cache.set("featured_collections", collection_ids, 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>{% include '_people.html' with people=item.genre role='genre' max=5 %}</div>
<div> <div>
{% if item.barcode %} {% if item.barcode %}
{% trans 'barcode' %}{{ item.barcode }} {% trans 'barcode' %}: {{ item.barcode }}
{% endif %} {% endif %}
</div> </div>
<div> <div>

View file

@ -26,7 +26,7 @@
<div>{% include '_people.html' with people=item.publisher role='publisher' max=2 %}</div> <div>{% include '_people.html' with people=item.publisher role='publisher' max=2 %}</div>
<div> <div>
{% if item.official_site %} {% if item.official_site %}
{% trans 'website' %}{{ item.official_site|urlizetrunc:24 }} {% trans 'website' %}: {{ item.official_site|urlizetrunc:24 }}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -6,9 +6,9 @@ from loguru import logger
from ninja import NinjaAPI, Schema from ninja import NinjaAPI, Schema
from ninja.pagination import PageNumberPagination as NinjaPageNumberPagination from ninja.pagination import PageNumberPagination as NinjaPageNumberPagination
from ninja.security import HttpBearer from ninja.security import HttpBearer
from oauth2_provider.oauth2_backends import OAuthLibCore
from oauth2_provider.oauth2_validators import OAuth2Validator from takahe.utils import Takahe
from oauthlib.oauth2 import Server from users.models.apidentity import APIdentity
PERMITTED_WRITE_METHODS = ["PUT", "POST", "DELETE", "PATCH"] PERMITTED_WRITE_METHODS = ["PUT", "POST", "DELETE", "PATCH"]
PERMITTED_READ_METHODS = ["GET", "HEAD", "OPTIONS"] PERMITTED_READ_METHODS = ["GET", "HEAD", "OPTIONS"]
@ -16,23 +16,38 @@ PERMITTED_READ_METHODS = ["GET", "HEAD", "OPTIONS"]
class OAuthAccessTokenAuth(HttpBearer): class OAuthAccessTokenAuth(HttpBearer):
def authenticate(self, request, token) -> bool: def authenticate(self, request, token) -> bool:
if not token or not request.user.is_authenticated: if not token:
logger.debug("API auth: no access token or user not authenticated") logger.debug("API auth: no access token provided")
return False 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 request_method = request.method
if request_method in PERMITTED_READ_METHODS: if request_method in PERMITTED_READ_METHODS:
request_scopes = ["read"] request_scope = "read"
elif request_method in PERMITTED_WRITE_METHODS: elif request_method in PERMITTED_WRITE_METHODS:
request_scopes = ["write"] request_scope = "write"
else: else:
logger.debug("API auth: unsupported HTTP method")
return False return False
validator = OAuth2Validator() if request_scope not in tk.scopes:
core = OAuthLibCore(Server(validator)) logger.debug("API auth: scope not allowed")
valid, oauthlib_req = core.verify_request(request, scopes=request_scopes) return False
if not valid: identity = APIdentity.objects.filter(pk=tk.identity_id).first()
logger.debug(f"API auth: request scope {request_scopes} not verified") if not identity:
return valid 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): class EmptyResult(Schema):

View file

@ -6,7 +6,7 @@
<!-- <a href="/pages/rules/">{% trans 'Rules' %}</a> --> <!-- <a href="/pages/rules/">{% trans 'Rules' %}</a> -->
<!-- <a href="/pages/terms/">{% trans 'Terms' %}</a> --> <!-- <a href="/pages/terms/">{% trans 'Terms' %}</a> -->
<a href="{% url 'users:announcements' %}">{% trans 'Announcements' %}</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 }}" <a title="{{ neodb_version }}"
target="_blank" target="_blank"
rel="noopener" rel="noopener"

View file

@ -33,11 +33,14 @@
<body> <body>
{% include "_header.html" %} {% include "_header.html" %}
<main class="container"> <main class="container">
<h3> <h3>Developer Console</h3>
Developer Console | <a href="{% url 'oauth2_provider:list' %}">{% trans "Your applications" %}</a> <p>
</h3> <li class="empty">
<p class="empty"> By using our APIs, you agree to our <a href="/pages/terms">Terms of Service</a>.
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> </p>
<details {% if token %}open{% endif %}> <details {% if token %}open{% endif %}>
<summary> <summary>
@ -48,7 +51,8 @@
<input type="text" <input type="text"
readonly readonly
value="{{ token | default:'Once generated, token will only be shown once here, previous tokens will be revoked.' }}"> 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> </form>
<p> <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> 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> <summary>
<a>How to authorize</a> <a>How to authorize</a>
</summary> </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>) Please check <a href="https://neodb.net/api/">NeoDB documentation</a> for details.
<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.
</details> </details>
<div id="swagger-ui" data-theme="light"></div> <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> <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("home/", home, name="home"),
path("me/", me, name="me"), path("me/", me, name="me"),
path("nodeinfo/2.0/", nodeinfo2), path("nodeinfo/2.0/", nodeinfo2),
path("developer/", console, name="developer"),
re_path("^~neodb~(?P<uri>.+)", ap_redirect), re_path("^~neodb~(?P<uri>.+)", ap_redirect),
] ]

View file

@ -1,14 +1,14 @@
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.cache import cache from django.core.cache import cache
from django.db import connection
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from boofilsic import __version__ from boofilsic import __version__
from users.models import User from takahe.utils import Takahe
from .api import api
@login_required @login_required
@ -75,3 +75,24 @@ def error_404(request, exception=None):
def error_500(request, exception=None): def error_500(request, exception=None):
return render(request, "500.html", status=500, context={"exception": exception}) 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_BACKEND: local://www/media/
TAKAHE_MEDIA_ROOT: /www/media TAKAHE_MEDIA_ROOT: /www/media
TAKAHE_USE_PROXY_HEADERS: true TAKAHE_USE_PROXY_HEADERS: true
TAKAHE_ALLOW_USER_MIGRATION: true
TAKAHE_STATOR_CONCURRENCY: ${TAKAHE_STATOR_CONCURRENCY:-4} TAKAHE_STATOR_CONCURRENCY: ${TAKAHE_STATOR_CONCURRENCY:-4}
TAKAHE_STATOR_CONCURRENCY_PER_MODEL: ${TAKAHE_STATOR_CONCURRENCY_PER_MODEL:-2} TAKAHE_STATOR_CONCURRENCY_PER_MODEL: ${TAKAHE_STATOR_CONCURRENCY_PER_MODEL:-2}
TAKAHE_VAPID_PUBLIC_KEY: 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 ## 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/). - NeoDB offers [APIs to manage user collections](api.md), and [Mastodon client compatible API](https://docs.joinmastodon.org/client/) to manage user posts.
- [Mastodon client compatible API](https://docs.joinmastodon.org/client/) is also available
- For those interested in developing for NeoDB, please refer to the [development](development.md) section for basic instructions to get started. - 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; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden" 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 %}> {% if shelf_type == k %}class="status-selector"{% else %}class="status-selector outline"{% endif %}>
{{ v }} {{ v }}
</button> </button>

View file

@ -11,6 +11,7 @@ nav:
- configuration.md - configuration.md
- troubleshooting.md - troubleshooting.md
- development.md - development.md
- api.md
- origin.md - origin.md
theme: theme:
logo: assets/logo.svg 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 from journal.models import render_md
return mark_safe(render_md(self.text)) 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.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.images import ImageFile from django.core.files.images import ImageFile
from django.core.signing import b62_encode
from django.db.models import Count from django.db.models import Count
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -1010,3 +1011,50 @@ class Takahe:
if featured: if featured:
identity.fanout("tag_unfeatured", subject_hashtag_id=hashtag) identity.fanout("tag_unfeatured", subject_hashtag_id=hashtag)
featured.delete() 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 import Schema
from ninja.security import django_auth from ninja.security import django_auth
from oauth2_provider.decorators import protected_resource
from common.api import * from common.api import *

View file

@ -214,7 +214,7 @@
<details> <details>
<summary>{% trans 'Applications' %}</summary> <summary>{% trans 'Applications' %}</summary>
<p> <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> </p>
</details> </details>
</article> </article>