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

- Developer Console | {% trans "Your applications" %} -

-

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

    - {% block app-form-title %} - {% trans "Edit application" %} {{ application.name }} - {% endblock app-form-title %} -

    - {% csrf_token %} -
    - {% for field in form %} - - {% endfor %} -
    -
    - {% for error in form.non_field_errors %}{{ error }}{% endfor %} -
    -
    -
    - - {% trans "Go Back" %} - - -
    -
    -
    -{% 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" %} -

    -
    - {% if not request.user.mastodon_acct %} -

    - Please connect to a Fediverse identity before creating an application. -

    - {% elif applications %} - - {% trans "New Application" %} - {% else %} -

    - {% trans "No applications defined" %}. {% trans "Click here" %} {% trans "if you want to register a new one" %} -

    - {% endif %} -
    -{% 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 }} 访问你的帐户吗?

    -
    -
    - {% csrf_token %} - {% if not application.is_official %} -

    - {{ application.name }} 是由 @{{ application.user.identity.handle }} 创建和维护的应用程序。 - {{ site_name }}无法保证其安全性和有效性,请自行验证确认后再授权。 -

    - {% endif %} - {% if application.url %}应用网址: {{ application.url | urlize }}{% endif %} -

    {{ application.description_html|safe }}

    - {% for field in form %} - {% if field.is_hidden %}{{ field }}{% endif %} - {% endfor %} -

    授权后这个应用将能读写你帐户的全部数据。

    - {% comment %}

    {% trans "Application requires the following permissions" %}

    -
      - {% for scope in scopes_descriptions %}
    • {{ scope }}
    • {% endfor %} -
    {% endcomment %} - {{ form.errors }} - {{ form.non_field_errors }} -
    -
    - - -
    -
    -
    - {% 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" %}