diff --git a/boofilsic/settings.py b/boofilsic/settings.py
index d4b1778c..00d1edb9 100644
--- a/boofilsic/settings.py
+++ b/boofilsic/settings.py
@@ -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
diff --git a/boofilsic/urls.py b/boofilsic/urls.py
index ceecf73c..ec7e7eb0 100644
--- a/boofilsic/urls.py
+++ b/boofilsic/urls.py
@@ -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),
diff --git a/catalog/jobs/discover.py b/catalog/jobs/discover.py
index 6a5f4cf1..ef8b0dcf 100644
--- a/catalog/jobs/discover.py
+++ b/catalog/jobs/discover.py
@@ -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)
diff --git a/catalog/templates/album.html b/catalog/templates/album.html
index 55a87735..1fffdfed 100644
--- a/catalog/templates/album.html
+++ b/catalog/templates/album.html
@@ -25,7 +25,7 @@
{% include '_people.html' with people=item.genre role='genre' max=5 %}
{% if item.barcode %}
- {% trans 'barcode' %}{{ item.barcode }}
+ {% trans 'barcode' %}: {{ item.barcode }}
{% endif %}
diff --git a/catalog/templates/game.html b/catalog/templates/game.html
index 66276ee5..95fced23 100644
--- a/catalog/templates/game.html
+++ b/catalog/templates/game.html
@@ -26,7 +26,7 @@
{% include '_people.html' with people=item.publisher role='publisher' max=2 %}
{% if item.official_site %}
- {% trans 'website' %}{{ item.official_site|urlizetrunc:24 }}
+ {% trans 'website' %}: {{ item.official_site|urlizetrunc:24 }}
{% endif %}
{% endblock %}
diff --git a/common/api.py b/common/api.py
index 9e9988c6..cea6e9b0 100644
--- a/common/api.py
+++ b/common/api.py
@@ -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):
diff --git a/common/templates/_footer.html b/common/templates/_footer.html
index 513a85b1..bef464a1 100644
--- a/common/templates/_footer.html
+++ b/common/templates/_footer.html
@@ -6,7 +6,7 @@
{% trans 'Announcements' %}
-
{% trans 'Developer' %}
+
{% trans 'Developer' %}
{% include "_header.html" %}
-
-
- By using our APIs, you agree to our Terms of Service .
+
Developer Console
+
+
+ By using our APIs, you agree to our Terms of Service .
+
+
+ In additional to APIs below, {{ site_name }} also supports a subset of Mastodon APIs .
+
@@ -48,7 +51,8 @@
-
+
Click Authorize
button below, input your token there to invoke APIs with your account, which is required for APIs like /api/me
@@ -62,32 +66,7 @@
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
)
-
- 1. Guide your user to open this URL
-
- 2. Once authorizated by user, it will redirect to https://example.org/callback
with a code
parameter:
-
- 3. Obtain access token with the following POST request:
-
- and access token will be returned in the response:
-
- 4. Use the access token to access protected endpoints like /api/me
-
- and response will be returned accordingly:
-
- more endpoints can be found in API Documentation below.
+ Please check NeoDB documentation for details.
diff --git a/common/urls.py b/common/urls.py
index 9686ac1a..c5d53dbb 100644
--- a/common/urls.py
+++ b/common/urls.py
@@ -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.+)", ap_redirect),
]
diff --git a/common/views.py b/common/views.py
index e825783d..f9d8a0cc 100644
--- a/common/views.py
+++ b/common/views.py
@@ -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)
diff --git a/compose.yml b/compose.yml
index c682c8d5..2d17d191 100644
--- a/compose.yml
+++ b/compose.yml
@@ -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:
diff --git a/developer/__init__.py b/developer/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/developer/admin.py b/developer/admin.py
deleted file mode 100644
index 8c38f3f3..00000000
--- a/developer/admin.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.
diff --git a/developer/apps.py b/developer/apps.py
deleted file mode 100644
index 2ee12960..00000000
--- a/developer/apps.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from django.apps import AppConfig
-
-
-class DeveloperConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- name = "developer"
diff --git a/developer/migrations/0001_initial.py b/developer/migrations/0001_initial.py
deleted file mode 100644
index aa05f04b..00000000
--- a/developer/migrations/0001_initial.py
+++ /dev/null
@@ -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,
- },
- ),
- ]
diff --git a/developer/migrations/0002_alter_application_user.py b/developer/migrations/0002_alter_application_user.py
deleted file mode 100644
index 0a8c33f0..00000000
--- a/developer/migrations/0002_alter_application_user.py
+++ /dev/null
@@ -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,
- ),
- ),
- ]
diff --git a/developer/migrations/0003_alter_application_redirect_uris.py b/developer/migrations/0003_alter_application_redirect_uris.py
deleted file mode 100644
index 079c613c..00000000
--- a/developer/migrations/0003_alter_application_redirect_uris.py
+++ /dev/null
@@ -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"
- ),
- ),
- ]
diff --git a/developer/migrations/__init__.py b/developer/migrations/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/developer/models.py b/developer/models.py
deleted file mode 100644
index 1ca80821..00000000
--- a/developer/models.py
+++ /dev/null
@@ -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)
diff --git a/developer/templates/oauth2_provider/application_detail.html b/developer/templates/oauth2_provider/application_detail.html
deleted file mode 100644
index 547377b0..00000000
--- a/developer/templates/oauth2_provider/application_detail.html
+++ /dev/null
@@ -1,45 +0,0 @@
-{% extends "oauth2_provider/base.html" %}
-{% load i18n %}
-{% block content %}
-
-
{{ application.name }}
-
-
-
- {% trans "Client ID" %}
-
-
-
-
-
- {% trans "URL" %}
-
- {{ application.url | default:"" | urlize }}
-
-
-
- {% trans "Description" %}
-
- {{ application.description_html|safe }}
-
-
-
- {% trans "Redirect Uris" %}
- {% if not application.redirect_uris %}WARNING: no redirect uris have been set, authorization may not work.{% endif %}
-
-
-
-
-
-
-
-{% endblock content %}
diff --git a/developer/templates/oauth2_provider/application_form.html b/developer/templates/oauth2_provider/application_form.html
deleted file mode 100644
index 1efa7b5b..00000000
--- a/developer/templates/oauth2_provider/application_form.html
+++ /dev/null
@@ -1,39 +0,0 @@
-{% extends "oauth2_provider/base.html" %}
-{% load i18n %}
-{% block content %}
-
-{% endblock %}
diff --git a/developer/templates/oauth2_provider/application_list.html b/developer/templates/oauth2_provider/application_list.html
deleted file mode 100644
index 5bfdc4af..00000000
--- a/developer/templates/oauth2_provider/application_list.html
+++ /dev/null
@@ -1,27 +0,0 @@
-{% extends "oauth2_provider/base.html" %}
-{% load i18n %}
-{% block content %}
-
- Developer Console | {% trans "Your applications" %}
-
-
-{% endblock content %}
diff --git a/developer/templates/oauth2_provider/authorize.html b/developer/templates/oauth2_provider/authorize.html
deleted file mode 100644
index 9714ac18..00000000
--- a/developer/templates/oauth2_provider/authorize.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{% extends "oauth2_provider/base.html" %}
-{% load i18n %}
-{% block content %}
-
- {% if not error %}
-
- 授权 {{ application.name }} 访问你的帐户吗?
-
-
- {% else %}
- Error: {{ error.error }}
- {{ error.description }}
- {% endif %}
-
-{% endblock %}
diff --git a/developer/templates/oauth2_provider/base.html b/developer/templates/oauth2_provider/base.html
deleted file mode 100644
index 9c3cc247..00000000
--- a/developer/templates/oauth2_provider/base.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{% load i18n %}
-
-
-
-
-
- {% block title %}
- {% endblock title %}
- {{ site_name }}
- {% include "common_libs.html" %}
-
-
-
- {% include "_header.html" %}
-
- {% block content %}
- {% endblock content %}
-
- {% include "_footer.html" %}
-
-
diff --git a/developer/tests.py b/developer/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/developer/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/developer/urls.py b/developer/urls.py
deleted file mode 100644
index 40db32d9..00000000
--- a/developer/urls.py
+++ /dev/null
@@ -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[\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[\w-]+)/$",
- oauth2_views.ApplicationDetail.as_view(),
- name="detail",
- ),
- re_path(
- r"^developer/applications/(?P[\w-]+)/delete/$",
- oauth2_views.ApplicationDelete.as_view(),
- name="delete",
- ),
- re_path(
- r"^developer/applications/(?P[\w-]+)/update/$",
- ApplicationUpdate.as_view(),
- name="update",
- ),
-]
-
-urlpatterns = [
- path("", include((_urlpatterns, "oauth2_provider"))),
-]
diff --git a/developer/views.py b/developer/views.py
deleted file mode 100644
index f85ecfb6..00000000
--- a/developer/views.py
+++ /dev/null
@@ -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)
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 00000000..149f53d5
--- /dev/null
+++ b/docs/api.md
@@ -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"}
+```
diff --git a/docs/index.md b/docs/index.md
index f6037654..88da54fe 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -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.
diff --git a/journal/templates/mark.html b/journal/templates/mark.html
index 272fded7..e9894c93 100644
--- a/journal/templates/mark.html
+++ b/journal/templates/mark.html
@@ -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 }}
diff --git a/mkdocs.yml b/mkdocs.yml
index a5bf0b70..60f09ea0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -11,6 +11,7 @@ nav:
- configuration.md
- troubleshooting.md
- development.md
+ - api.md
- origin.md
theme:
logo: assets/logo.svg
diff --git a/neodb-takahe b/neodb-takahe
index 1522364d..01cacb7d 160000
--- a/neodb-takahe
+++ b/neodb-takahe
@@ -1 +1 @@
-Subproject commit 1522364d4fd399ba2560656e7e714ee7cea7a905
+Subproject commit 01cacb7dce19f7fadcda7e629b78b2752d74eece
diff --git a/takahe/models.py b/takahe/models.py
index ba6526d5..2f16ab41 100644
--- a/takahe/models.py
+++ b/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"
diff --git a/takahe/utils.py b/takahe/utils.py
index b0f1defc..fdffaa47 100644
--- a/takahe/utils.py
+++ b/takahe/utils.py
@@ -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()
diff --git a/users/api.py b/users/api.py
index f06d8d39..cdfdeb71 100644
--- a/users/api.py
+++ b/users/api.py
@@ -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 *
diff --git a/users/templates/users/preferences.html b/users/templates/users/preferences.html
index 8f1b2c59..56623e92 100644
--- a/users/templates/users/preferences.html
+++ b/users/templates/users/preferences.html
@@ -214,7 +214,7 @@
{% trans 'Applications' %}
- {% trans "View authorized applications" %}
+ {% trans "View authorized applications" %}