login via threads.net
This commit is contained in:
parent
ac4f12c831
commit
2bd3aaa78d
57 changed files with 1132 additions and 764 deletions
|
@ -102,6 +102,9 @@ env = environ.FileAwareEnv(
|
||||||
DISCORD_WEBHOOKS=(dict, {"user-report": None}),
|
DISCORD_WEBHOOKS=(dict, {"user-report": None}),
|
||||||
# Slack API token, for sending exceptions to Slack, may deprecate in future
|
# Slack API token, for sending exceptions to Slack, may deprecate in future
|
||||||
SLACK_API_TOKEN=(str, ""),
|
SLACK_API_TOKEN=(str, ""),
|
||||||
|
THREADS_APP_ID=(str, ""),
|
||||||
|
THREADS_APP_SECRET=(str, ""),
|
||||||
|
BLUESKY_LOGIN_ENABLED=(bool, False),
|
||||||
# SSL only, better be True for production security
|
# SSL only, better be True for production security
|
||||||
SSL_ONLY=(bool, False),
|
SSL_ONLY=(bool, False),
|
||||||
NEODB_SENTRY_DSN=(str, ""),
|
NEODB_SENTRY_DSN=(str, ""),
|
||||||
|
@ -174,8 +177,11 @@ elif _parsed_email_url.scheme:
|
||||||
else:
|
else:
|
||||||
ENABLE_LOGIN_EMAIL = False
|
ENABLE_LOGIN_EMAIL = False
|
||||||
|
|
||||||
ENABLE_LOGIN_THREADS = False
|
|
||||||
ENABLE_LOGIN_BLUESKY = False
|
THREADS_APP_ID = env("THREADS_APP_ID")
|
||||||
|
THREADS_APP_SECRET = env("THREADS_APP_SECRET")
|
||||||
|
|
||||||
|
BLUESKY_LOGIN_ENABLED = env("BLUESKY_LOGIN_ENABLED")
|
||||||
|
|
||||||
SITE_DOMAIN = env("NEODB_SITE_DOMAIN").lower()
|
SITE_DOMAIN = env("NEODB_SITE_DOMAIN").lower()
|
||||||
SITE_INFO = {
|
SITE_INFO = {
|
||||||
|
|
|
@ -27,9 +27,10 @@ urlpatterns = [
|
||||||
path("login/", login),
|
path("login/", login),
|
||||||
path("markdownx/", include("markdownx.urls")),
|
path("markdownx/", include("markdownx.urls")),
|
||||||
path("account/", include("users.urls")),
|
path("account/", include("users.urls")),
|
||||||
|
path("account/", include("mastodon.urls")),
|
||||||
path(
|
path(
|
||||||
"users/connect/",
|
"users/connect/",
|
||||||
RedirectView.as_view(url="/account/connect", query_string=True),
|
RedirectView.as_view(url="/mastodon/login", query_string=True),
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"auth/edit", # some apps like elk will use this url
|
"auth/edit", # some apps like elk will use this url
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import django
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.checks import Error, Warning
|
from django.core.checks import Error, Warning
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
@ -7,7 +6,6 @@ from catalog.search.models import Indexer
|
||||||
from common.models import JobManager
|
from common.models import JobManager
|
||||||
from takahe.models import Config as TakaheConfig
|
from takahe.models import Config as TakaheConfig
|
||||||
from takahe.models import Domain as TakaheDomain
|
from takahe.models import Domain as TakaheDomain
|
||||||
from takahe.models import Follow as TakaheFollow
|
|
||||||
from takahe.models import Identity as TakaheIdentity
|
from takahe.models import Identity as TakaheIdentity
|
||||||
from takahe.models import Relay as TakaheRelay
|
from takahe.models import Relay as TakaheRelay
|
||||||
from takahe.models import User as TakaheUser
|
from takahe.models import User as TakaheUser
|
||||||
|
|
|
@ -119,13 +119,6 @@
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{% if request.user.is_authenticated and not request.user.mastodon_username and not request.user.username %}
|
|
||||||
<ul class="messages" style="text-align:center">
|
|
||||||
<li class="error">
|
|
||||||
<a href="{% url 'users:info' %}">{% trans "Set a username." %}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<ul class="messages" style="text-align:center">
|
<ul class="messages" style="text-align:center">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
|
|
|
@ -54,16 +54,26 @@
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if identity.user.mastodon_account %}
|
{% if identity.user.mastodon %}
|
||||||
<span>
|
<span>
|
||||||
<a href="{{ identity.user.mastodon_account.url }}"
|
<a href="{{ identity.user.mastodon.url }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
title="@{{ identity.user.mastodon_acct }}">
|
title="@{{ identity.user.mastodon.handle }}">
|
||||||
<i class="fa-brands fa-mastodon"></i>
|
<i class="fa-brands fa-mastodon"></i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if identity.user.threads %}
|
||||||
|
<span>
|
||||||
|
<a href="{{ identity.user.threads.url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
title="@{{ identity.user.threads.handle }}">
|
||||||
|
<i class="fa-brands fa-threads"></i>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
{% elif request.user.is_authenticated %}
|
{% elif request.user.is_authenticated %}
|
||||||
{% include 'users/profile_actions.html' %}
|
{% include 'users/profile_actions.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
from django import template
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.html import format_html
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthTokenNode(template.Node):
|
|
||||||
def render(self, context):
|
|
||||||
request = context.get("request")
|
|
||||||
oauth_token = request.user.mastodon_token if request else ""
|
|
||||||
return format_html(oauth_token)
|
|
||||||
|
|
||||||
|
|
||||||
@register.tag
|
|
||||||
def oauth_token(parser, token):
|
|
||||||
return OAuthTokenNode()
|
|
|
@ -11,6 +11,12 @@ from takahe.utils import Takahe
|
||||||
from .api import api
|
from .api import api
|
||||||
|
|
||||||
|
|
||||||
|
def render_error(request, title, message=""):
|
||||||
|
return render(
|
||||||
|
request, "common/error.html", {"msg": title, "secondary_msg": message}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def me(request):
|
def me(request):
|
||||||
if not request.user.registration_complete:
|
if not request.user.registration_complete:
|
||||||
|
|
|
@ -67,6 +67,9 @@ x-shared:
|
||||||
TAKAHE_VAPID_PRIVATE_KEY:
|
TAKAHE_VAPID_PRIVATE_KEY:
|
||||||
TAKAHE_DEBUG: ${NEODB_DEBUG:-False}
|
TAKAHE_DEBUG: ${NEODB_DEBUG:-False}
|
||||||
TAKAHE_VENV: /takahe-venv
|
TAKAHE_VENV: /takahe-venv
|
||||||
|
THREADS_APP_ID:
|
||||||
|
THREADS_APP_SECRET:
|
||||||
|
BLUESKY_LOGIN_ENABLED:
|
||||||
SPOTIFY_API_KEY:
|
SPOTIFY_API_KEY:
|
||||||
TMDB_API_V3_KEY:
|
TMDB_API_V3_KEY:
|
||||||
GOOGLE_API_KEY:
|
GOOGLE_API_KEY:
|
||||||
|
|
|
@ -80,6 +80,12 @@ You should see the same JSON response as above, and the site is now accessible t
|
||||||
|
|
||||||
## Register an account and make it admin
|
## Register an account and make it admin
|
||||||
|
|
||||||
|
If you have email sender properly configured, use this command to create an admin with a verified email (use any password as it won't be saved)
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose --profile production run --rm shell neodb-manage createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
Now open `https://yourdomain.tld` in your browser and register an account, assuming username `admin`
|
Now open `https://yourdomain.tld` in your browser and register an account, assuming username `admin`
|
||||||
|
|
||||||
add the following line to `.env` to make it an admin account:
|
add the following line to `.env` to make it an admin account:
|
||||||
|
|
|
@ -8,14 +8,14 @@ from typing import TYPE_CHECKING, Any, Self
|
||||||
# from deepmerge import always_merger
|
# from deepmerge import always_merger
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.signing import b62_decode, b62_encode
|
from django.core.signing import b62_decode, b62_encode
|
||||||
from django.db import connection, models
|
from django.db import models
|
||||||
from django.db.models import Avg, CharField, Count, Q
|
from django.db.models import CharField, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from polymorphic.models import PolymorphicModel
|
from polymorphic.models import PolymorphicModel
|
||||||
|
|
||||||
from catalog.common.models import AvailableItemCategory, Item, ItemCategory
|
from catalog.common.models import Item, ItemCategory
|
||||||
from catalog.models import item_categories, item_content_types
|
from catalog.models import item_categories, item_content_types
|
||||||
from takahe.utils import Takahe
|
from takahe.utils import Takahe
|
||||||
from users.models import APIdentity, User
|
from users.models import APIdentity, User
|
||||||
|
@ -316,7 +316,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
|
||||||
|
|
||||||
def sync_to_mastodon(self, delete_existing=False):
|
def sync_to_mastodon(self, delete_existing=False):
|
||||||
user = self.owner.user
|
user = self.owner.user
|
||||||
if not user.mastodon_site:
|
if not user.mastodon:
|
||||||
return
|
return
|
||||||
if user.preference.mastodon_repost_mode == 1:
|
if user.preference.mastodon_repost_mode == 1:
|
||||||
if delete_existing:
|
if delete_existing:
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from catalog.models import Item, ItemCategory
|
from catalog.models import Item
|
||||||
from mastodon.models import get_spoiler_text
|
from mastodon.models import get_spoiler_text
|
||||||
from takahe.utils import Takahe
|
from takahe.utils import Takahe
|
||||||
from users.models import APIdentity, User
|
from users.models import APIdentity, User
|
||||||
|
@ -309,8 +309,11 @@ class Mark:
|
||||||
post_as_new = shelf_type != last_shelf_type or visibility != last_visibility
|
post_as_new = shelf_type != last_shelf_type or visibility != last_visibility
|
||||||
classic_crosspost = user.preference.mastodon_repost_mode == 1
|
classic_crosspost = user.preference.mastodon_repost_mode == 1
|
||||||
append = (
|
append = (
|
||||||
f"@{user.mastodon_acct}\n"
|
f"@{user.mastodon.handle}\n"
|
||||||
if visibility > 0 and share_to_mastodon and not classic_crosspost
|
if visibility > 0
|
||||||
|
and share_to_mastodon
|
||||||
|
and not classic_crosspost
|
||||||
|
and user.mastodon
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
post = Takahe.post_mark(self, post_as_new, append)
|
post = Takahe.post_mark(self, post_as_new, append)
|
||||||
|
|
|
@ -58,7 +58,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ collection.owner.url }}">{{ collection.owner.mastodon_account.display_name }}</a>
|
<a href="{{ collection.owner.url }}">{{ collection.owner.display_name }}</a>
|
||||||
<span class="handler">@{{ collection.owner.handle }}</span>
|
<span class="handler">@{{ collection.owner.handle }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{% if request.user.mastodon_acct %}
|
{% if request.user.mastodon %}
|
||||||
<label for="id_share_to_mastodon">
|
<label for="id_share_to_mastodon">
|
||||||
<input role="switch"
|
<input role="switch"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
@ -109,7 +109,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{% if request.user.mastodon_acct %}
|
{% if request.user.mastodon %}
|
||||||
<label for="id_share_to_mastodon">
|
<label for="id_share_to_mastodon">
|
||||||
<input role="switch"
|
<input role="switch"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
@ -238,10 +238,8 @@
|
||||||
{% include "_sidebar.html" with show_progress=1 show_profile=1 %}
|
{% include "_sidebar.html" with show_progress=1 show_profile=1 %}
|
||||||
</main>
|
</main>
|
||||||
{% include "_footer.html" %}
|
{% include "_footer.html" %}
|
||||||
{% if identity.user and identity.user.mastodon_account %}
|
{% if identity.user and identity.user.mastodon %}
|
||||||
<a href="{{ identity.user.mastodon_account.url }}"
|
<a href="{{ identity.user.mastodon.url }}" rel="me" style="display:none">Mastodon verification</a>
|
||||||
rel="me"
|
|
||||||
style="display:none">Mastodon verification</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -58,7 +58,7 @@
|
||||||
var cats = JSON.parse(document.getElementById('cat-data').textContent);
|
var cats = JSON.parse(document.getElementById('cat-data').textContent);
|
||||||
var data = JSON.parse(document.getElementById('data').textContent);
|
var data = JSON.parse(document.getElementById('data').textContent);
|
||||||
var opts = {
|
var opts = {
|
||||||
title: "@{{ identity.user.mastodon_acct | default:identity.full_handle }} - {{ year }}",
|
title: "@{{ identity.user.mastodon.handle | default:identity.full_handle }} - {{ year }}",
|
||||||
element: '#viz0',
|
element: '#viz0',
|
||||||
font: 1,
|
font: 1,
|
||||||
data: data,
|
data: data,
|
||||||
|
|
|
@ -172,8 +172,8 @@ def share_collection(
|
||||||
else (
|
else (
|
||||||
_("shared {username}'s collection").format(
|
_("shared {username}'s collection").format(
|
||||||
username=(
|
username=(
|
||||||
" @" + collection.owner.user.mastodon_acct + " "
|
" @" + collection.owner.user.mastodon.handle + " "
|
||||||
if collection.owner.user.mastodon_acct
|
if collection.owner.user.mastodon
|
||||||
else " " + collection.owner.username + " "
|
else " " + collection.owner.username + " "
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.exceptions import BadRequest, ObjectDoesNotExist, PermissionDenied
|
from django.core.exceptions import BadRequest, PermissionDenied
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import F, Min, OuterRef, Subquery
|
from django.db.models import F, Min, OuterRef, Subquery
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
@ -14,7 +14,6 @@ from common.utils import (
|
||||||
AuthedHttpRequest,
|
AuthedHttpRequest,
|
||||||
PageLinksGenerator,
|
PageLinksGenerator,
|
||||||
get_uuid_or_404,
|
get_uuid_or_404,
|
||||||
profile_identity_required,
|
|
||||||
target_identity_required,
|
target_identity_required,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,7 +28,9 @@ def render_relogin(request):
|
||||||
request,
|
request,
|
||||||
"common/error.html",
|
"common/error.html",
|
||||||
{
|
{
|
||||||
"url": reverse("users:connect") + "?domain=" + request.user.mastodon_site,
|
"url": reverse("mastodon:connect")
|
||||||
|
+ "?domain="
|
||||||
|
+ request.user.mastodon.domain,
|
||||||
"msg": _("Data saved but unable to crosspost to Fediverse instance."),
|
"msg": _("Data saved but unable to crosspost to Fediverse instance."),
|
||||||
"secondary_msg": _(
|
"secondary_msg": _(
|
||||||
"Redirecting to your Fediverse instance now to re-authenticate."
|
"Redirecting to your Fediverse instance now to re-authenticate."
|
||||||
|
|
|
@ -5,12 +5,15 @@ from django.shortcuts import render
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from catalog.models import *
|
from catalog.models import *
|
||||||
from common.utils import AuthedHttpRequest
|
from common.utils import (
|
||||||
|
AuthedHttpRequest,
|
||||||
|
profile_identity_required,
|
||||||
|
target_identity_required,
|
||||||
|
)
|
||||||
from takahe.utils import Takahe
|
from takahe.utils import Takahe
|
||||||
|
|
||||||
from ..forms import *
|
from ..forms import *
|
||||||
from ..models import *
|
from ..models import *
|
||||||
from .common import profile_identity_required, target_identity_required
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
from django.contrib.auth.backends import ModelBackend, UserModel
|
from django.contrib.auth.backends import ModelBackend
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from mastodon.models.common import SocialAccount
|
from mastodon.models.common import SocialAccount
|
||||||
|
|
||||||
from .models import Mastodon
|
|
||||||
|
|
||||||
|
|
||||||
class OAuth2Backend(ModelBackend):
|
class OAuth2Backend(ModelBackend):
|
||||||
"""Used to glue OAuth2 and Django User model"""
|
"""Used to glue OAuth2 and Django User model"""
|
||||||
|
|
|
@ -8,7 +8,7 @@ class Bluesky:
|
||||||
|
|
||||||
|
|
||||||
class BlueskyAccount(SocialAccount):
|
class BlueskyAccount(SocialAccount):
|
||||||
username = jsondata.CharField(json_field_name="access_data", default="")
|
app_username = jsondata.CharField(json_field_name="access_data", default="")
|
||||||
app_password = jsondata.EncryptedTextField(
|
app_password = jsondata.EncryptedTextField(
|
||||||
json_field_name="access_data", default=""
|
json_field_name="access_data", default=""
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,7 +2,6 @@ from django.db import models
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from loguru import logger
|
|
||||||
from typedmodels.models import TypedModel
|
from typedmodels.models import TypedModel
|
||||||
|
|
||||||
from catalog.common import jsondata
|
from catalog.common import jsondata
|
||||||
|
@ -67,12 +66,15 @@ class SocialAccount(TypedModel):
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.platform}:{self.handle}"
|
return f"({self.pk}){self.platform}#{self.handle}:{self.uid}@{self.domain}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def platform(self) -> Platform:
|
def platform(self) -> Platform:
|
||||||
return Platform(self.type.replace("mastodon.", "", 1).replace("account", "", 1))
|
return Platform(self.type.replace("mastodon.", "", 1).replace("account", "", 1))
|
||||||
|
|
||||||
|
def sync_later(self):
|
||||||
|
pass
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
# skip cached_property, datetime and other non-serializable fields
|
# skip cached_property, datetime and other non-serializable fields
|
||||||
d = {
|
d = {
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
import random
|
import random
|
||||||
from datetime import timedelta
|
|
||||||
from os.path import exists
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import django_rq
|
import django_rq
|
||||||
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.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.core.signing import TimestampSigner, b62_decode, b62_encode
|
from django.core.signing import b62_encode
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from catalog.common import jsondata
|
|
||||||
|
|
||||||
from .common import SocialAccount
|
from .common import SocialAccount
|
||||||
|
|
||||||
_code_ttl = 60 * 15
|
_code_ttl = 60 * 15
|
||||||
|
@ -24,6 +19,14 @@ class EmailAccount(SocialAccount):
|
||||||
|
|
||||||
|
|
||||||
class Email:
|
class Email:
|
||||||
|
@staticmethod
|
||||||
|
def new_account(email: str) -> EmailAccount | None:
|
||||||
|
sp = email.split("@", 1)
|
||||||
|
if len(sp) != 2:
|
||||||
|
return None
|
||||||
|
account = EmailAccount(handle=email, uid=sp[0], domain=sp[1])
|
||||||
|
return account
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _send(email, subject, body):
|
def _send(email, subject, body):
|
||||||
try:
|
try:
|
||||||
|
@ -88,8 +91,4 @@ class Email:
|
||||||
existing_account = EmailAccount.objects.filter(handle__iexact=email).first()
|
existing_account = EmailAccount.objects.filter(handle__iexact=email).first()
|
||||||
if existing_account:
|
if existing_account:
|
||||||
return existing_account
|
return existing_account
|
||||||
sp = email.split("@", 1)
|
return Email.new_account(email)
|
||||||
if len(sp) != 2:
|
|
||||||
return None
|
|
||||||
account = EmailAccount(handle=email, uid=sp[0], domain=sp[1])
|
|
||||||
return account
|
|
||||||
|
|
|
@ -2,17 +2,14 @@ import functools
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
import time
|
|
||||||
import typing
|
import typing
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import django_rq
|
import django_rq
|
||||||
import httpx
|
|
||||||
import requests
|
import requests
|
||||||
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.base import ContentFile
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
@ -24,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from catalog.common import jsondata
|
from catalog.common import jsondata
|
||||||
|
from takahe.utils import Takahe
|
||||||
|
|
||||||
from .common import SocialAccount
|
from .common import SocialAccount
|
||||||
|
|
||||||
|
@ -395,7 +393,7 @@ def verify_client(mast_app):
|
||||||
def obtain_token(site, code, request):
|
def obtain_token(site, code, request):
|
||||||
"""Returns token if success else None."""
|
"""Returns token if success else None."""
|
||||||
mast_app = MastodonApplication.objects.get(domain_name=site)
|
mast_app = MastodonApplication.objects.get(domain_name=site)
|
||||||
redirect_uri = request.build_absolute_uri(reverse("users:login_oauth"))
|
redirect_uri = request.build_absolute_uri(reverse("mastodon:oauth"))
|
||||||
payload = {
|
payload = {
|
||||||
"client_id": mast_app.client_id,
|
"client_id": mast_app.client_id,
|
||||||
"client_secret": mast_app.client_secret,
|
"client_secret": mast_app.client_secret,
|
||||||
|
@ -520,7 +518,7 @@ def get_or_create_fediverse_application(login_domain):
|
||||||
|
|
||||||
|
|
||||||
def get_mastodon_login_url(app, login_domain, request):
|
def get_mastodon_login_url(app, login_domain, request):
|
||||||
url = request.build_absolute_uri(reverse("users:login_oauth"))
|
url = request.build_absolute_uri(reverse("mastodon:oauth"))
|
||||||
version = app.server_version or ""
|
version = app.server_version or ""
|
||||||
scope = (
|
scope = (
|
||||||
settings.MASTODON_LEGACY_CLIENT_SCOPE
|
settings.MASTODON_LEGACY_CLIENT_SCOPE
|
||||||
|
@ -838,3 +836,6 @@ class MastodonAccount(SocialAccount):
|
||||||
spoiler_text,
|
spoiler_text,
|
||||||
attachments,
|
attachments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def sync_later(self):
|
||||||
|
Takahe.fetch_remote_identity(self.handle)
|
||||||
|
|
|
@ -1,9 +1,208 @@
|
||||||
|
import functools
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from catalog.common import jsondata
|
||||||
|
|
||||||
from .common import SocialAccount
|
from .common import SocialAccount
|
||||||
|
|
||||||
|
get = functools.partial(
|
||||||
|
requests.get,
|
||||||
|
timeout=settings.MASTODON_TIMEOUT,
|
||||||
|
headers={"User-Agent": settings.NEODB_USER_AGENT},
|
||||||
|
)
|
||||||
|
put = functools.partial(
|
||||||
|
requests.put,
|
||||||
|
timeout=settings.MASTODON_TIMEOUT,
|
||||||
|
headers={"User-Agent": settings.NEODB_USER_AGENT},
|
||||||
|
)
|
||||||
|
post = functools.partial(
|
||||||
|
requests.post,
|
||||||
|
timeout=settings.MASTODON_TIMEOUT,
|
||||||
|
headers={"User-Agent": settings.NEODB_USER_AGENT},
|
||||||
|
)
|
||||||
|
delete = functools.partial(
|
||||||
|
requests.post,
|
||||||
|
timeout=settings.MASTODON_TIMEOUT,
|
||||||
|
headers={"User-Agent": settings.NEODB_USER_AGENT},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Threads:
|
class Threads:
|
||||||
pass
|
SCOPE = "threads_basic,threads_content_publish"
|
||||||
|
DOMAIN = "threads.net"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_auth_url(request: HttpRequest):
|
||||||
|
redirect_url = request.build_absolute_uri(reverse("mastodon:threads_oauth"))
|
||||||
|
url = f"https://threads.net/oauth/authorize?client_id={settings.THREADS_APP_ID}&redirect_uri={redirect_url}&scope={Threads.SCOPE}&response_type=code"
|
||||||
|
return url
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def obtain_token(
|
||||||
|
request: HttpRequest, code: str
|
||||||
|
) -> tuple[str, int, str] | tuple[None, None, None]:
|
||||||
|
redirect_url = request.build_absolute_uri(reverse("mastodon:threads_oauth"))
|
||||||
|
payload = {
|
||||||
|
"client_id": settings.THREADS_APP_ID,
|
||||||
|
"client_secret": settings.THREADS_APP_SECRET,
|
||||||
|
"redirect_uri": redirect_url,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
}
|
||||||
|
url = "https://graph.threads.net/oauth/access_token"
|
||||||
|
try:
|
||||||
|
response = post(url, data=payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error {url} {e}")
|
||||||
|
return None, None, None
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Error {url} {response.status_code}")
|
||||||
|
return None, None, None
|
||||||
|
data = response.json()
|
||||||
|
if data.get("error_type"):
|
||||||
|
logger.warning(f"Error {url} {data}")
|
||||||
|
return None, None, None
|
||||||
|
short_token = data.get("access_token")
|
||||||
|
user_id = data.get("user_id")
|
||||||
|
|
||||||
|
# exchange for a 60-days token
|
||||||
|
url = f"https://graph.threads.net/access_token?grant_type=th_exchange_token&client_secret={settings.THREADS_APP_SECRET}&access_token={short_token}"
|
||||||
|
try:
|
||||||
|
response = get(url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error {url} {e}")
|
||||||
|
return None, None, None
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Error {url} {response.status_code}")
|
||||||
|
return None, None, None
|
||||||
|
data = response.json()
|
||||||
|
if data.get("error_type"):
|
||||||
|
logger.warning(f"Error {url} {data}")
|
||||||
|
return None, None, None
|
||||||
|
return data.get("access_token"), data.get("expires_in"), str(user_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def refresh_token(token: str) -> tuple[str, int] | tuple[None, None]:
|
||||||
|
url = f"https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token={token}"
|
||||||
|
try:
|
||||||
|
response = get(url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error {url} {e}")
|
||||||
|
return None, None
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Error {url} {response.status_code}")
|
||||||
|
return None, None
|
||||||
|
data = response.json()
|
||||||
|
if data.get("error_type"):
|
||||||
|
logger.warning(f"Error {url} {data}")
|
||||||
|
return None, None
|
||||||
|
return data.get("access_token"), data.get("expires_in")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_profile(
|
||||||
|
token: str, user_id: str | None = None
|
||||||
|
) -> dict[str, str | int] | None:
|
||||||
|
url = f'https://graph.threads.net/v1.0/{user_id or "me"}?fields=id,username,name,threads_profile_picture_url,threads_biography&access_token={token}'
|
||||||
|
try:
|
||||||
|
response = get(url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error {url} {e}")
|
||||||
|
return None
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Error {url} {response.status_code}")
|
||||||
|
return None
|
||||||
|
data = response.json()
|
||||||
|
if data.get("error_type"):
|
||||||
|
logger.warning(f"Error {url} {data}")
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authenticate(request: HttpRequest, code: str) -> "ThreadsAccount | None":
|
||||||
|
token, expire, uid = Threads.obtain_token(request, code)
|
||||||
|
if not token or not expire:
|
||||||
|
return None
|
||||||
|
expires_at = timezone.now() + timedelta(seconds=expire)
|
||||||
|
existing_account = ThreadsAccount.objects.filter(
|
||||||
|
uid=uid, domain=Threads.DOMAIN
|
||||||
|
).first()
|
||||||
|
if existing_account:
|
||||||
|
existing_account.access_token = token
|
||||||
|
existing_account.token_expires_at = expires_at
|
||||||
|
existing_account.last_reachable = timezone.now()
|
||||||
|
existing_account.save(update_fields=["access_data", "last_reachable"])
|
||||||
|
existing_account.refresh()
|
||||||
|
return existing_account
|
||||||
|
account = ThreadsAccount()
|
||||||
|
account.uid = uid
|
||||||
|
account.access_token = token
|
||||||
|
account.domain = Threads.DOMAIN
|
||||||
|
account.token_expires_at = expires_at
|
||||||
|
account.refresh(save=False)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
class ThreadsAccount(SocialAccount):
|
class ThreadsAccount(SocialAccount):
|
||||||
pass
|
access_token = jsondata.EncryptedTextField(
|
||||||
|
json_field_name="access_data", default=""
|
||||||
|
)
|
||||||
|
token_expires_at = jsondata.DateTimeField(json_field_name="access_data", null=True)
|
||||||
|
username = jsondata.CharField(json_field_name="account_data", default="")
|
||||||
|
threads_profile_picture_url = jsondata.CharField(
|
||||||
|
json_field_name="account_data", default=""
|
||||||
|
)
|
||||||
|
threads_biography = jsondata.CharField(json_field_name="account_data", default="")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return f"https://threads.net/@{self.handle}"
|
||||||
|
|
||||||
|
def check_alive(self, save=True) -> bool:
|
||||||
|
# refresh token
|
||||||
|
if not self.access_token:
|
||||||
|
logger.warning(f"{self} token missing")
|
||||||
|
return False
|
||||||
|
if self.token_expires_at and timezone.now() > self.token_expires_at:
|
||||||
|
logger.warning(f"{self} token expired")
|
||||||
|
return False
|
||||||
|
if self.last_reachable and timezone.now() < self.last_reachable + timedelta(
|
||||||
|
hours=1
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
token, expire = Threads.refresh_token(self.access_token)
|
||||||
|
if not token or not expire:
|
||||||
|
return False
|
||||||
|
self.access_token = token
|
||||||
|
self.last_reachable = timezone.now()
|
||||||
|
self.token_expires_at = self.last_reachable + timedelta(seconds=expire)
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["access_data", "last_reachable"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def refresh(self, save=True) -> bool:
|
||||||
|
if not self.access_token:
|
||||||
|
logger.warning(f"{self} token missing")
|
||||||
|
return False
|
||||||
|
if self.token_expires_at and timezone.now() > self.token_expires_at:
|
||||||
|
logger.warning(f"{self} token expired")
|
||||||
|
return False
|
||||||
|
data = Threads.get_profile(self.access_token)
|
||||||
|
if not data:
|
||||||
|
logger.warning("{self} unable to get profile")
|
||||||
|
return False
|
||||||
|
if self.handle != data["username"]:
|
||||||
|
if self.handle:
|
||||||
|
logger.info(f'{self} handle changed to {data["username"]}')
|
||||||
|
self.handle = data["username"]
|
||||||
|
self.account_data = data
|
||||||
|
self.last_refresh = timezone.now()
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["account_data", "handle", "last_refresh"])
|
||||||
|
return True
|
||||||
|
|
23
mastodon/urls.py
Normal file
23
mastodon/urls.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import *
|
||||||
|
|
||||||
|
app_name = "mastodon"
|
||||||
|
urlpatterns = [
|
||||||
|
# Mastodon
|
||||||
|
path("login/oauth", mastodon_oauth, name="oauth"),
|
||||||
|
path("mastodon/login", mastodon_login, name="login"),
|
||||||
|
path("mastodon/reconnect", mastodon_reconnect, name="reconnect"),
|
||||||
|
path("mastodon/disconnect", mastodon_disconnect, name="mastodon_disconnect"),
|
||||||
|
# Email
|
||||||
|
path("email/login", email_login, name="email_login"),
|
||||||
|
path("email/verify", email_verify, name="email_verify"),
|
||||||
|
# Threads
|
||||||
|
path("threads/login", threads_login, name="threads_login"),
|
||||||
|
path("threads/oauth", threads_oauth, name="threads_oauth"),
|
||||||
|
path("threads/reconnect", threads_reconnect, name="threads_reconnect"),
|
||||||
|
path("threads/disconnect", threads_disconnect, name="threads_disconnect"),
|
||||||
|
path("threads/uninstall", threads_uninstall, name="threads_uninstall"),
|
||||||
|
path("threads/delete", threads_delete, name="threads_delete"),
|
||||||
|
# Bluesky
|
||||||
|
]
|
3
mastodon/views/__init__.py
Normal file
3
mastodon/views/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from .email import *
|
||||||
|
from .mastodon import *
|
||||||
|
from .threads import *
|
86
mastodon/views/common.py
Normal file
86
mastodon/views/common.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from common.views import render_error
|
||||||
|
from mastodon.models.common import SocialAccount
|
||||||
|
from users.views.account import auth_login, logout_takahe
|
||||||
|
|
||||||
|
|
||||||
|
def process_verified_account(request: HttpRequest, account: SocialAccount):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
# add/update linked identity
|
||||||
|
return reconnect_account(request, account)
|
||||||
|
if account.user:
|
||||||
|
# existing user
|
||||||
|
return login_existing_user(request, account)
|
||||||
|
else:
|
||||||
|
# check invite and ask for username
|
||||||
|
return register_new_user(request, account)
|
||||||
|
|
||||||
|
|
||||||
|
def login_existing_user(request: HttpRequest, account: SocialAccount):
|
||||||
|
user = authenticate(request, social_account=account)
|
||||||
|
if not user:
|
||||||
|
return render_error(_("Authentication failed"), _("Invalid user."))
|
||||||
|
existing_user = account.user
|
||||||
|
auth_login(request, existing_user)
|
||||||
|
account.sync_later()
|
||||||
|
if not existing_user.username or not existing_user.identity:
|
||||||
|
# this should not happen
|
||||||
|
response = redirect(reverse("users:register"))
|
||||||
|
else:
|
||||||
|
response = redirect(request.session.get("next_url", reverse("common:home")))
|
||||||
|
request.session.pop("next_url", None)
|
||||||
|
return logout_takahe(response)
|
||||||
|
|
||||||
|
|
||||||
|
def register_new_user(request: HttpRequest, account: SocialAccount):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return render_error(_("Registration failed"), _("User already logged in."))
|
||||||
|
request.session["verified_account"] = account.to_dict()
|
||||||
|
return redirect(reverse("users:register"))
|
||||||
|
|
||||||
|
|
||||||
|
def reconnect_account(request, account: SocialAccount):
|
||||||
|
if account.user == request.user:
|
||||||
|
return render_error(
|
||||||
|
request, _("Unable to update login information: identical identity.")
|
||||||
|
)
|
||||||
|
elif account.user:
|
||||||
|
return render_error(
|
||||||
|
request, _("Unable to update login information: identity in use.")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# TODO add confirmation screen
|
||||||
|
request.user.reconnect_account(account)
|
||||||
|
if request.session.get("new_user", 0):
|
||||||
|
# new user finishes linking email
|
||||||
|
del request.session["new_user"]
|
||||||
|
return render(request, "users/welcome.html")
|
||||||
|
else:
|
||||||
|
account.sync_later()
|
||||||
|
messages.add_message(
|
||||||
|
request,
|
||||||
|
messages.INFO,
|
||||||
|
_("Login information updated.") + account.handle,
|
||||||
|
)
|
||||||
|
return redirect(reverse("users:info"))
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect_identity(request, account):
|
||||||
|
if not account:
|
||||||
|
return render_error(_("Disconnect identity failed"), _("Identity not found."))
|
||||||
|
if request.user != account.user:
|
||||||
|
return render_error(_("Disconnect identity failed"), _("Invalid user."))
|
||||||
|
with transaction.atomic():
|
||||||
|
if request.user.social_accounts.all().count() <= 1:
|
||||||
|
return render_error(
|
||||||
|
_("Unlink identity failed"), _("Unable to unlink last login identity.")
|
||||||
|
)
|
||||||
|
account.delete()
|
||||||
|
return redirect(reverse("users:info"))
|
56
mastodon/views/email.py
Normal file
56
mastodon/views/email.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from django.core.validators import EmailValidator
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from common.views import render_error
|
||||||
|
|
||||||
|
from ..models import Email
|
||||||
|
from .common import process_verified_account
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def email_login(request: HttpRequest):
|
||||||
|
login_email = request.POST.get("email", "")
|
||||||
|
try:
|
||||||
|
EmailValidator()(login_email)
|
||||||
|
except Exception:
|
||||||
|
return render_error(request, _("Invalid email address"))
|
||||||
|
Email.send_login_email(request, login_email, "login")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"users/verify.html",
|
||||||
|
{
|
||||||
|
"msg": _("Verification"),
|
||||||
|
"secondary_msg": _(
|
||||||
|
"Verification email is being sent, please check your inbox."
|
||||||
|
),
|
||||||
|
"action": "login",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def email_verify(request: HttpRequest):
|
||||||
|
if request.method == "GET":
|
||||||
|
return render(request, "users/verify.html")
|
||||||
|
code = request.POST.get("code", "").strip()
|
||||||
|
if not code:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"users/verify.html",
|
||||||
|
{
|
||||||
|
"error": _("Invalid verification code"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
account = Email.authenticate(request, code)
|
||||||
|
if not account:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"users/verify.html",
|
||||||
|
{
|
||||||
|
"error": _("Invalid verification code"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return process_verified_account(request, account)
|
80
mastodon/views/mastodon.py
Normal file
80
mastodon/views/mastodon.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import BadRequest, ObjectDoesNotExist
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from common.views import render_error
|
||||||
|
from mastodon.models import Mastodon
|
||||||
|
from mastodon.views.common import disconnect_identity, process_verified_account
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def mastodon_login(request):
|
||||||
|
"""verify mastodon api server and redirect"""
|
||||||
|
login_domain = request.POST.get("domain") or request.GET.get("domain")
|
||||||
|
if not login_domain:
|
||||||
|
return render_error(request, _("Missing instance domain"))
|
||||||
|
login_domain = (
|
||||||
|
login_domain.strip().lower().split("//")[-1].split("/")[0].split("@")[-1]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
login_url = Mastodon.generate_auth_url(login_domain, request)
|
||||||
|
return redirect(login_url)
|
||||||
|
except Exception as e:
|
||||||
|
return render_error(
|
||||||
|
request, _("Error connecting to instance"), f"{login_domain} {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def mastodon_oauth(request):
|
||||||
|
"""handle redirect back from mastodon api server"""
|
||||||
|
code = request.GET.get("code")
|
||||||
|
if not code:
|
||||||
|
return render_error(
|
||||||
|
request,
|
||||||
|
_("Authentication failed"),
|
||||||
|
_("Invalid response from Fediverse instance."),
|
||||||
|
)
|
||||||
|
site = request.session.get("mastodon_domain")
|
||||||
|
if not site:
|
||||||
|
return render_error(
|
||||||
|
request,
|
||||||
|
_("Authentication failed"),
|
||||||
|
_("Invalid cookie data."),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
token, refresh_token = Mastodon.obtain_token(site, code, request)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
raise BadRequest(_("Invalid instance domain"))
|
||||||
|
if not token:
|
||||||
|
return render_error(
|
||||||
|
request,
|
||||||
|
_("Authentication failed"),
|
||||||
|
_("Invalid token from Fediverse instance."),
|
||||||
|
)
|
||||||
|
account = Mastodon.authenticate(site, token, refresh_token)
|
||||||
|
if not account:
|
||||||
|
return render_error(
|
||||||
|
request,
|
||||||
|
_("Authentication failed"),
|
||||||
|
_("Invalid account data from Fediverse instance."),
|
||||||
|
)
|
||||||
|
return process_verified_account(request, account)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def mastodon_reconnect(request):
|
||||||
|
"""relink to another mastodon from an existing logged-in user"""
|
||||||
|
if request.META.get("HTTP_AUTHORIZATION"):
|
||||||
|
raise BadRequest("Only for web login")
|
||||||
|
return mastodon_login(request)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@login_required
|
||||||
|
def mastodon_disconnect(request):
|
||||||
|
"""unlink mastodon from an existing logged-in user"""
|
||||||
|
return disconnect_identity(request, request.user.mastodon)
|
57
mastodon/views/threads.py
Normal file
57
mastodon/views/threads.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from common.views import render_error
|
||||||
|
|
||||||
|
from ..models import Threads
|
||||||
|
from .common import disconnect_identity, process_verified_account
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def threads_login(request: HttpRequest):
|
||||||
|
"""start login process via threads"""
|
||||||
|
return redirect(Threads.generate_auth_url(request))
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@login_required
|
||||||
|
def threads_reconnect(request: HttpRequest):
|
||||||
|
"""link another threads to an existing logged-in user"""
|
||||||
|
return redirect(Threads.generate_auth_url(request))
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@login_required
|
||||||
|
def threads_disconnect(request):
|
||||||
|
"""unlink threads from an existing logged-in user"""
|
||||||
|
return disconnect_identity(request, request.user.threads)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def threads_oauth(request: HttpRequest):
|
||||||
|
"""handle redirect back from threads"""
|
||||||
|
code = request.GET.get("code")
|
||||||
|
if not code:
|
||||||
|
return render_error(
|
||||||
|
_("Authentication failed"), request.GET.get("error_description", "")
|
||||||
|
)
|
||||||
|
account = Threads.authenticate(request, code)
|
||||||
|
if not account:
|
||||||
|
return render_error(
|
||||||
|
_("Authentication failed"), _("Invalid account data from Threads.")
|
||||||
|
)
|
||||||
|
return process_verified_account(request, account)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def threads_uninstall(request: HttpRequest):
|
||||||
|
return redirect(reverse("users:data"))
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def threads_delete(request: HttpRequest):
|
||||||
|
return redirect(reverse("users:data"))
|
|
@ -55,7 +55,8 @@ dependencies = [
|
||||||
"urlman",
|
"urlman",
|
||||||
"validators",
|
"validators",
|
||||||
"deepmerge>=1.1.1",
|
"deepmerge>=1.1.1",
|
||||||
"django-typed-models>=0.14.0",
|
"django-typed-models @ git+https://github.com/alphatownsman/django-typed-models.git",
|
||||||
|
"atproto>=0.0.48",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.rye]
|
[tool.rye]
|
||||||
|
@ -93,7 +94,7 @@ django_settings_module = "boofilsic.settings"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
exclude = ["neodb-takahe/*", "media", ".venv", ".git", "playground", "**/tests.py", "neodb", "**/migrations", "**/commands", "**/importers", "**/sites", "legacy" ]
|
exclude = ["neodb-takahe/*", "media", ".venv", ".git", "playground", "**/tests.py", "neodb", "**/migrations", "**/commands", "**/importers", "**/sites", "legacy" ]
|
||||||
lint.ignore = ["F401", "F403", "F405"]
|
lint.ignore = ["F403", "F405"]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = []
|
py-modules = []
|
||||||
|
|
|
@ -20,6 +20,7 @@ asgiref==3.8.1
|
||||||
# via django
|
# via django
|
||||||
# via django-cors-headers
|
# via django-cors-headers
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
|
atproto==0.0.48
|
||||||
attrs==23.2.0
|
attrs==23.2.0
|
||||||
# via aiohttp
|
# via aiohttp
|
||||||
babel==2.15.0
|
babel==2.15.0
|
||||||
|
@ -44,6 +45,7 @@ cfgv==3.4.0
|
||||||
charset-normalizer==3.3.2
|
charset-normalizer==3.3.2
|
||||||
# via requests
|
# via requests
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
|
# via atproto
|
||||||
# via black
|
# via black
|
||||||
# via djlint
|
# via djlint
|
||||||
# via mkdocs
|
# via mkdocs
|
||||||
|
@ -52,6 +54,7 @@ colorama==0.4.6
|
||||||
# via djlint
|
# via djlint
|
||||||
# via mkdocs-material
|
# via mkdocs-material
|
||||||
cryptography==42.0.8
|
cryptography==42.0.8
|
||||||
|
# via atproto
|
||||||
# via jwcrypto
|
# via jwcrypto
|
||||||
cssbeautifier==1.15.1
|
cssbeautifier==1.15.1
|
||||||
# via djlint
|
# via djlint
|
||||||
|
@ -103,11 +106,12 @@ django-slack==5.19.0
|
||||||
django-stubs==5.0.2
|
django-stubs==5.0.2
|
||||||
django-stubs-ext==5.0.2
|
django-stubs-ext==5.0.2
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
django-typed-models==0.14.0
|
django-typed-models @ git+https://github.com/alphatownsman/django-typed-models.git@03921e05b39d07d143519a435259f66387a088bc
|
||||||
django-tz-detect==0.5.0
|
django-tz-detect==0.5.0
|
||||||
django-user-messages==1.0.0
|
django-user-messages==1.0.0
|
||||||
djlint==1.34.1
|
djlint==1.34.1
|
||||||
dnspython==2.6.1
|
dnspython==2.6.1
|
||||||
|
# via atproto
|
||||||
easy-thumbnails==2.8.5
|
easy-thumbnails==2.8.5
|
||||||
editorconfig==0.12.4
|
editorconfig==0.12.4
|
||||||
# via cssbeautifier
|
# via cssbeautifier
|
||||||
|
@ -131,7 +135,8 @@ html-void-elements==0.1.0
|
||||||
# via djlint
|
# via djlint
|
||||||
httpcore==1.0.5
|
httpcore==1.0.5
|
||||||
# via httpx
|
# via httpx
|
||||||
httpx==0.27.0
|
httpx==0.26.0
|
||||||
|
# via atproto
|
||||||
identify==2.5.36
|
identify==2.5.36
|
||||||
# via pre-commit
|
# via pre-commit
|
||||||
idna==3.7
|
idna==3.7
|
||||||
|
@ -152,6 +157,8 @@ json5==0.9.25
|
||||||
jwcrypto==1.5.6
|
jwcrypto==1.5.6
|
||||||
# via django-oauth-toolkit
|
# via django-oauth-toolkit
|
||||||
langdetect==1.0.9
|
langdetect==1.0.9
|
||||||
|
libipld==1.2.3
|
||||||
|
# via atproto
|
||||||
libsass==0.23.0
|
libsass==0.23.0
|
||||||
listparser==0.20
|
listparser==0.20
|
||||||
loguru==0.7.2
|
loguru==0.7.2
|
||||||
|
@ -214,6 +221,7 @@ psycopg2-binary==2.9.9
|
||||||
pycparser==2.22
|
pycparser==2.22
|
||||||
# via cffi
|
# via cffi
|
||||||
pydantic==2.7.3
|
pydantic==2.7.3
|
||||||
|
# via atproto
|
||||||
# via django-ninja
|
# via django-ninja
|
||||||
pydantic-core==2.18.4
|
pydantic-core==2.18.4
|
||||||
# via pydantic
|
# via pydantic
|
||||||
|
@ -287,6 +295,7 @@ types-pyyaml==6.0.12.20240311
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
typesense==0.21.0
|
typesense==0.21.0
|
||||||
typing-extensions==4.12.1
|
typing-extensions==4.12.1
|
||||||
|
# via atproto
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
# via django-stubs-ext
|
# via django-stubs-ext
|
||||||
# via jwcrypto
|
# via jwcrypto
|
||||||
|
@ -307,5 +316,7 @@ watchdog==4.0.1
|
||||||
webencodings==0.5.1
|
webencodings==0.5.1
|
||||||
# via bleach
|
# via bleach
|
||||||
# via tinycss2
|
# via tinycss2
|
||||||
|
websockets==12.0
|
||||||
|
# via atproto
|
||||||
yarl==1.9.4
|
yarl==1.9.4
|
||||||
# via aiohttp
|
# via aiohttp
|
||||||
|
|
|
@ -19,6 +19,7 @@ anyio==4.4.0
|
||||||
asgiref==3.8.1
|
asgiref==3.8.1
|
||||||
# via django
|
# via django
|
||||||
# via django-cors-headers
|
# via django-cors-headers
|
||||||
|
atproto==0.0.48
|
||||||
attrs==23.2.0
|
attrs==23.2.0
|
||||||
# via aiohttp
|
# via aiohttp
|
||||||
beautifulsoup4==4.12.3
|
beautifulsoup4==4.12.3
|
||||||
|
@ -38,8 +39,10 @@ cffi==1.16.0
|
||||||
charset-normalizer==3.3.2
|
charset-normalizer==3.3.2
|
||||||
# via requests
|
# via requests
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
|
# via atproto
|
||||||
# via rq
|
# via rq
|
||||||
cryptography==42.0.8
|
cryptography==42.0.8
|
||||||
|
# via atproto
|
||||||
# via jwcrypto
|
# via jwcrypto
|
||||||
dateparser==1.2.0
|
dateparser==1.2.0
|
||||||
deepmerge==1.1.1
|
deepmerge==1.1.1
|
||||||
|
@ -82,10 +85,11 @@ django-rq==2.10.2
|
||||||
django-sass-processor==1.4.1
|
django-sass-processor==1.4.1
|
||||||
django-simple-history==3.7.0
|
django-simple-history==3.7.0
|
||||||
django-slack==5.19.0
|
django-slack==5.19.0
|
||||||
django-typed-models==0.14.0
|
django-typed-models @ git+https://github.com/alphatownsman/django-typed-models.git@03921e05b39d07d143519a435259f66387a088bc
|
||||||
django-tz-detect==0.5.0
|
django-tz-detect==0.5.0
|
||||||
django-user-messages==1.0.0
|
django-user-messages==1.0.0
|
||||||
dnspython==2.6.1
|
dnspython==2.6.1
|
||||||
|
# via atproto
|
||||||
easy-thumbnails==2.8.5
|
easy-thumbnails==2.8.5
|
||||||
et-xmlfile==1.1.0
|
et-xmlfile==1.1.0
|
||||||
# via openpyxl
|
# via openpyxl
|
||||||
|
@ -98,7 +102,8 @@ h11==0.14.0
|
||||||
# via httpcore
|
# via httpcore
|
||||||
httpcore==1.0.5
|
httpcore==1.0.5
|
||||||
# via httpx
|
# via httpx
|
||||||
httpx==0.27.0
|
httpx==0.26.0
|
||||||
|
# via atproto
|
||||||
idna==3.7
|
idna==3.7
|
||||||
# via anyio
|
# via anyio
|
||||||
# via httpx
|
# via httpx
|
||||||
|
@ -108,6 +113,8 @@ igdb-api-v4==0.3.2
|
||||||
jwcrypto==1.5.6
|
jwcrypto==1.5.6
|
||||||
# via django-oauth-toolkit
|
# via django-oauth-toolkit
|
||||||
langdetect==1.0.9
|
langdetect==1.0.9
|
||||||
|
libipld==1.2.3
|
||||||
|
# via atproto
|
||||||
libsass==0.23.0
|
libsass==0.23.0
|
||||||
listparser==0.20
|
listparser==0.20
|
||||||
loguru==0.7.2
|
loguru==0.7.2
|
||||||
|
@ -135,6 +142,7 @@ psycopg2-binary==2.9.9
|
||||||
pycparser==2.22
|
pycparser==2.22
|
||||||
# via cffi
|
# via cffi
|
||||||
pydantic==2.7.3
|
pydantic==2.7.3
|
||||||
|
# via atproto
|
||||||
# via django-ninja
|
# via django-ninja
|
||||||
pydantic-core==2.18.4
|
pydantic-core==2.18.4
|
||||||
# via pydantic
|
# via pydantic
|
||||||
|
@ -184,6 +192,7 @@ tinycss2==1.1.1
|
||||||
tqdm==4.66.4
|
tqdm==4.66.4
|
||||||
typesense==0.21.0
|
typesense==0.21.0
|
||||||
typing-extensions==4.12.1
|
typing-extensions==4.12.1
|
||||||
|
# via atproto
|
||||||
# via jwcrypto
|
# via jwcrypto
|
||||||
# via pydantic
|
# via pydantic
|
||||||
# via pydantic-core
|
# via pydantic-core
|
||||||
|
@ -198,5 +207,7 @@ validators==0.28.3
|
||||||
webencodings==0.5.1
|
webencodings==0.5.1
|
||||||
# via bleach
|
# via bleach
|
||||||
# via tinycss2
|
# via tinycss2
|
||||||
|
websockets==12.0
|
||||||
|
# via atproto
|
||||||
yarl==1.9.4
|
yarl==1.9.4
|
||||||
# via aiohttp
|
# via aiohttp
|
||||||
|
|
|
@ -15,12 +15,8 @@ class SocialTest(TestCase):
|
||||||
self.book1 = Edition.objects.create(title="Hyperion")
|
self.book1 = Edition.objects.create(title="Hyperion")
|
||||||
self.book2 = Edition.objects.create(title="Andymion")
|
self.book2 = Edition.objects.create(title="Andymion")
|
||||||
self.movie = Edition.objects.create(title="Fight Club")
|
self.movie = Edition.objects.create(title="Fight Club")
|
||||||
self.alice = User.register(
|
self.alice = User.register(username="Alice")
|
||||||
username="Alice", mastodon_site="MySpace", mastodon_username="Alice"
|
self.bob = User.register(username="Bob")
|
||||||
)
|
|
||||||
self.bob = User.register(
|
|
||||||
username="Bob", mastodon_site="KKCity", mastodon_username="Bob"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_timeline(self):
|
def test_timeline(self):
|
||||||
alice_feed = self.alice.identity.activity_manager
|
alice_feed = self.alice.identity.activity_manager
|
||||||
|
|
|
@ -7,7 +7,6 @@ from loguru import logger
|
||||||
from catalog.common import *
|
from catalog.common import *
|
||||||
from journal.models import (
|
from journal.models import (
|
||||||
Comment,
|
Comment,
|
||||||
Content,
|
|
||||||
Note,
|
Note,
|
||||||
Piece,
|
Piece,
|
||||||
PieceInteraction,
|
PieceInteraction,
|
||||||
|
@ -18,7 +17,7 @@ from journal.models import (
|
||||||
from users.middlewares import activate_language_for_user
|
from users.middlewares import activate_language_for_user
|
||||||
from users.models.apidentity import APIdentity
|
from users.models.apidentity import APIdentity
|
||||||
|
|
||||||
from .models import Follow, Identity, Post, TimelineEvent
|
from .models import Identity, Post, TimelineEvent
|
||||||
from .utils import Takahe
|
from .utils import Takahe
|
||||||
|
|
||||||
_supported_ap_catalog_item_types = [
|
_supported_ap_catalog_item_types = [
|
||||||
|
@ -155,7 +154,8 @@ def post_interacted(interaction_pk, interaction, post_pk, identity_pk):
|
||||||
if (
|
if (
|
||||||
interaction == "boost"
|
interaction == "boost"
|
||||||
and p.local
|
and p.local
|
||||||
and p.owner.user.mastodon_acct == apid.full_handle
|
and p.owner.user.mastodon
|
||||||
|
and p.owner.user.mastodon.handle == apid.full_handle
|
||||||
):
|
):
|
||||||
# ignore boost by oneself
|
# ignore boost by oneself
|
||||||
TimelineEvent.objects.filter(
|
TimelineEvent.objects.filter(
|
||||||
|
|
|
@ -628,8 +628,6 @@ class Takahe:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def post_mark(mark, share_as_new_post: bool, append_content="") -> Post | None:
|
def post_mark(mark, share_as_new_post: bool, append_content="") -> Post | None:
|
||||||
from catalog.common import ItemCategory
|
|
||||||
|
|
||||||
user = mark.owner.user
|
user = mark.owner.user
|
||||||
stars = _rating_to_emoji(mark.rating_grade, 1)
|
stars = _rating_to_emoji(mark.rating_grade, 1)
|
||||||
item_link = f"{settings.SITE_INFO['site_url']}/~neodb~{mark.item.url}"
|
item_link = f"{settings.SITE_INFO['site_url']}/~neodb~{mark.item.url}"
|
||||||
|
@ -804,7 +802,7 @@ class Takahe:
|
||||||
return FediverseHtmlParser(linebreaks_filter(txt)).html
|
return FediverseHtmlParser(linebreaks_filter(txt)).html
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_state(obj: Post | Relay, state: str):
|
def update_state(obj: Post | Relay | Identity, state: str):
|
||||||
obj.state = state
|
obj.state = state
|
||||||
obj.state_changed = timezone.now()
|
obj.state_changed = timezone.now()
|
||||||
obj.state_next_attempt = None
|
obj.state_next_attempt = None
|
||||||
|
|
509
users/account.py
509
users/account.py
|
@ -1,509 +0,0 @@
|
||||||
from datetime import timedelta
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import django_rq
|
|
||||||
from django import forms
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import auth, messages
|
|
||||||
from django.contrib.auth import authenticate
|
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.core.exceptions import BadRequest, ObjectDoesNotExist
|
|
||||||
from django.core.validators import EmailValidator
|
|
||||||
from django.db import transaction
|
|
||||||
from django.shortcuts import redirect, render
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from common.config import *
|
|
||||||
from common.utils import AuthedHttpRequest
|
|
||||||
from journal.models import remove_data_by_user
|
|
||||||
from mastodon.models import Email, Mastodon
|
|
||||||
from mastodon.models.common import Platform, SocialAccount
|
|
||||||
from mastodon.models.email import EmailAccount
|
|
||||||
from takahe.utils import Takahe
|
|
||||||
|
|
||||||
from .models import User
|
|
||||||
from .tasks import *
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def login(request):
|
|
||||||
selected_domain = request.GET.get("domain", default="")
|
|
||||||
sites = Mastodon.get_sites()
|
|
||||||
if request.GET.get("next"):
|
|
||||||
request.session["next_url"] = request.GET.get("next")
|
|
||||||
invite_status = -1 if settings.INVITE_ONLY else 0
|
|
||||||
if settings.INVITE_ONLY and request.GET.get("invite"):
|
|
||||||
if Takahe.verify_invite(request.GET.get("invite")):
|
|
||||||
invite_status = 1
|
|
||||||
request.session["invite"] = request.GET.get("invite")
|
|
||||||
else:
|
|
||||||
invite_status = -2
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"users/login.html",
|
|
||||||
{
|
|
||||||
"sites": sites,
|
|
||||||
"scope": quote(settings.MASTODON_CLIENT_SCOPE),
|
|
||||||
"selected_domain": selected_domain,
|
|
||||||
"allow_any_site": settings.MASTODON_ALLOW_ANY_SITE,
|
|
||||||
"enable_email": settings.ENABLE_LOGIN_EMAIL,
|
|
||||||
"enable_threads": settings.ENABLE_LOGIN_THREADS,
|
|
||||||
"enable_bluesky": settings.ENABLE_LOGIN_BLUESKY,
|
|
||||||
"invite_status": invite_status,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# connect will send verification email or redirect to mastodon server
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def connect(request):
|
|
||||||
if request.method == "POST" and request.POST.get("method") == "email":
|
|
||||||
login_email = request.POST.get("email", "")
|
|
||||||
try:
|
|
||||||
EmailValidator()(login_email)
|
|
||||||
except Exception:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{"msg": _("Invalid email address")},
|
|
||||||
)
|
|
||||||
Email.send_login_email(request, login_email, "login")
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"users/verify.html",
|
|
||||||
{
|
|
||||||
"msg": _("Verification"),
|
|
||||||
"secondary_msg": _(
|
|
||||||
"Verification email is being sent, please check your inbox."
|
|
||||||
),
|
|
||||||
"action": "login",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
login_domain = (
|
|
||||||
request.session["swap_domain"]
|
|
||||||
if request.session.get("swap_login")
|
|
||||||
else (request.POST.get("domain") or request.GET.get("domain"))
|
|
||||||
)
|
|
||||||
if not login_domain:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Missing instance domain"),
|
|
||||||
"secondary_msg": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
login_domain = (
|
|
||||||
login_domain.strip().lower().split("//")[-1].split("/")[0].split("@")[-1]
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
login_url = Mastodon.generate_auth_url(login_domain, request)
|
|
||||||
return redirect(login_url)
|
|
||||||
except Exception as e:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Error connecting to instance"),
|
|
||||||
"secondary_msg": f"{login_domain} {e}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# mastodon server redirect back to here
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def connect_redirect_back(request):
|
|
||||||
code = request.GET.get("code")
|
|
||||||
if not code:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Invalid response from Fediverse instance."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
site = request.session.get("mastodon_domain")
|
|
||||||
if not site:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Invalid cookie data."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
token, refresh_token = Mastodon.obtain_token(site, code, request)
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
raise BadRequest(_("Invalid instance domain"))
|
|
||||||
if not token:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Invalid token from Fediverse instance."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.session.get("swap_login", False) and request.user.is_authenticated:
|
|
||||||
# swap login for existing user
|
|
||||||
return swap_login(request, token, site, refresh_token)
|
|
||||||
|
|
||||||
account = Mastodon.authenticate(site, token, refresh_token)
|
|
||||||
if not account:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Invalid account data from Fediverse instance."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if account.user: # existing user
|
|
||||||
user: User | None = authenticate(request, social_account=account) # type: ignore
|
|
||||||
if not user:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Invalid user."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return login_existing_user(request, user)
|
|
||||||
elif not settings.MASTODON_ALLOW_ANY_SITE: # directly create a new user
|
|
||||||
new_user = User.register(
|
|
||||||
account=account,
|
|
||||||
username=account.username,
|
|
||||||
)
|
|
||||||
auth_login(request, new_user)
|
|
||||||
return render(request, "users/welcome.html")
|
|
||||||
else: # check invite and ask for username
|
|
||||||
return register_new_user(request, account)
|
|
||||||
|
|
||||||
|
|
||||||
def register_new_user(request, account: SocialAccount):
|
|
||||||
if settings.INVITE_ONLY:
|
|
||||||
if not Takahe.verify_invite(request.session.get("invite")):
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Registration is for invitation only"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
del request.session["invite"]
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
auth.logout(request)
|
|
||||||
request.session["verified_account"] = account.to_dict()
|
|
||||||
if account.platform == Platform.EMAIL:
|
|
||||||
email_readyonly = True
|
|
||||||
data = {"email": account.handle}
|
|
||||||
else:
|
|
||||||
email_readyonly = False
|
|
||||||
data = {"email": ""}
|
|
||||||
form = RegistrationForm(data)
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"users/register.html",
|
|
||||||
{"form": form, "email_readyonly": email_readyonly},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def login_existing_user(request, existing_user):
|
|
||||||
auth_login(request, existing_user)
|
|
||||||
if not existing_user.username or not existing_user.identity:
|
|
||||||
response = redirect(reverse("users:register"))
|
|
||||||
elif request.session.get("next_url") is not None:
|
|
||||||
response = redirect(request.session.get("next_url"))
|
|
||||||
del request.session["next_url"]
|
|
||||||
else:
|
|
||||||
response = redirect(reverse("common:home"))
|
|
||||||
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def logout(request):
|
|
||||||
return auth_logout(request)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["POST"])
|
|
||||||
def reconnect(request):
|
|
||||||
if request.META.get("HTTP_AUTHORIZATION"):
|
|
||||||
raise BadRequest("Only for web login")
|
|
||||||
request.session["swap_login"] = True
|
|
||||||
request.session["swap_domain"] = request.POST["domain"]
|
|
||||||
return connect(request)
|
|
||||||
|
|
||||||
|
|
||||||
class RegistrationForm(forms.ModelForm):
|
|
||||||
email = forms.EmailField(required=False)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ["username"]
|
|
||||||
|
|
||||||
def clean_username(self):
|
|
||||||
username = self.cleaned_data.get("username")
|
|
||||||
if username and self.instance and self.instance.username:
|
|
||||||
username = self.instance.username
|
|
||||||
elif (
|
|
||||||
username
|
|
||||||
and User.objects.filter(username__iexact=username)
|
|
||||||
.exclude(pk=self.instance.pk if self.instance else -1)
|
|
||||||
.exists()
|
|
||||||
):
|
|
||||||
raise forms.ValidationError(_("This username is already in use."))
|
|
||||||
return username
|
|
||||||
|
|
||||||
def clean_email(self):
|
|
||||||
email = self.cleaned_data.get("email", "").strip()
|
|
||||||
if (
|
|
||||||
email
|
|
||||||
and EmailAccount.objects.filter(handle__iexact=email)
|
|
||||||
.exclude(user_id=self.instance.pk if self.instance else -1)
|
|
||||||
.exists()
|
|
||||||
):
|
|
||||||
raise forms.ValidationError(_("This email address is already in use."))
|
|
||||||
return email
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def verify_code(request):
|
|
||||||
if request.method == "GET":
|
|
||||||
return render(request, "users/verify.html")
|
|
||||||
code = request.POST.get("code", "").strip()
|
|
||||||
if not code:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"users/verify.html",
|
|
||||||
{
|
|
||||||
"error": _("Invalid verification code"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
account = Email.authenticate(request, code)
|
|
||||||
if not account:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"users/verify.html",
|
|
||||||
{
|
|
||||||
"error": _("Invalid verification code"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
# existing logged in user to verify a pending email
|
|
||||||
if request.user.email_account == account:
|
|
||||||
# same email, nothing to do
|
|
||||||
return render(request, "users/welcome.html")
|
|
||||||
if account.user and account.user != request.user:
|
|
||||||
# email used by another user
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Email already in use"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
with transaction.atomic():
|
|
||||||
if request.user.email_account:
|
|
||||||
request.user.email_account.delete()
|
|
||||||
account.user = request.user
|
|
||||||
account.save()
|
|
||||||
if request.session.get("new_user", 0):
|
|
||||||
try:
|
|
||||||
del request.session["new_user"]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
return render(request, "users/welcome.html")
|
|
||||||
else:
|
|
||||||
return redirect(reverse("users:info"))
|
|
||||||
if account.user:
|
|
||||||
# existing user: log back in
|
|
||||||
user = authenticate(request, social_account=account)
|
|
||||||
if user:
|
|
||||||
return login_existing_user(request, user)
|
|
||||||
else:
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"common/error.html",
|
|
||||||
{
|
|
||||||
"msg": _("Authentication failed"),
|
|
||||||
"secondary_msg": _("Invalid user."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# new user: check invite and ask for username
|
|
||||||
return register_new_user(request, account)
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def register(request: AuthedHttpRequest):
|
|
||||||
if not settings.MASTODON_ALLOW_ANY_SITE:
|
|
||||||
return render(request, "users/welcome.html")
|
|
||||||
form = RegistrationForm(
|
|
||||||
request.POST,
|
|
||||||
instance=(
|
|
||||||
User.objects.get(pk=request.user.pk)
|
|
||||||
if request.user.is_authenticated
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
verified_account = SocialAccount.from_dict(request.session.get("verified_account"))
|
|
||||||
email_readonly = (
|
|
||||||
verified_account is not None and verified_account.platform == Platform.EMAIL
|
|
||||||
)
|
|
||||||
error = None
|
|
||||||
if request.method == "POST" and form.is_valid():
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
# logged in user to change email
|
|
||||||
current_email = (
|
|
||||||
request.user.email_account.handle
|
|
||||||
if request.user.email_account
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
form.cleaned_data["email"]
|
|
||||||
and form.cleaned_data["email"] != current_email
|
|
||||||
):
|
|
||||||
Email.send_login_email(request, form.cleaned_data["email"], "verify")
|
|
||||||
return render(request, "users/verify.html")
|
|
||||||
else:
|
|
||||||
# new user finishes login process
|
|
||||||
if not form.cleaned_data["username"]:
|
|
||||||
error = _("Valid username required")
|
|
||||||
elif User.objects.filter(
|
|
||||||
username__iexact=form.cleaned_data["username"]
|
|
||||||
).exists():
|
|
||||||
error = _("Username in use")
|
|
||||||
else:
|
|
||||||
# create new user
|
|
||||||
new_user = User.register(
|
|
||||||
username=form.cleaned_data["username"], account=verified_account
|
|
||||||
)
|
|
||||||
auth_login(request, new_user)
|
|
||||||
if not email_readonly and form.cleaned_data["email"]:
|
|
||||||
# verify email if presented
|
|
||||||
Email.send_login_email(
|
|
||||||
request, form.cleaned_data["email"], "verify"
|
|
||||||
)
|
|
||||||
request.session["new_user"] = 1
|
|
||||||
return render(request, "users/verify.html")
|
|
||||||
return render(request, "users/welcome.html")
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"users/register.html",
|
|
||||||
{"form": form, "email_readonly": email_readonly, "error": error},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def swap_login(request, token, site, refresh_token):
|
|
||||||
del request.session["swap_login"]
|
|
||||||
del request.session["swap_domain"]
|
|
||||||
account = Mastodon.authenticate(site, token, refresh_token)
|
|
||||||
current_user = request.user
|
|
||||||
if account:
|
|
||||||
if account.user == current_user:
|
|
||||||
messages.add_message(
|
|
||||||
request,
|
|
||||||
messages.ERROR,
|
|
||||||
_("Unable to update login information: identical identity."),
|
|
||||||
)
|
|
||||||
elif account.user:
|
|
||||||
messages.add_message(
|
|
||||||
request,
|
|
||||||
messages.ERROR,
|
|
||||||
_("Unable to update login information: identity in use."),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
with transaction.atomic():
|
|
||||||
if current_user.mastodon:
|
|
||||||
current_user.mastodon.delete()
|
|
||||||
account.user = current_user
|
|
||||||
account.save()
|
|
||||||
current_user.mastodon_username = account.username
|
|
||||||
current_user.mastodon_id = account.account_data["id"]
|
|
||||||
current_user.mastodon_site = account.domain
|
|
||||||
current_user.mastodon_token = account.access_token
|
|
||||||
current_user.mastodon_refresh_token = account.refresh_token
|
|
||||||
current_user.mastodon_account = account.account_data
|
|
||||||
current_user.save(
|
|
||||||
update_fields=[
|
|
||||||
"username",
|
|
||||||
"mastodon_id",
|
|
||||||
"mastodon_username",
|
|
||||||
"mastodon_site",
|
|
||||||
"mastodon_token",
|
|
||||||
"mastodon_refresh_token",
|
|
||||||
"mastodon_account",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
django_rq.get_queue("mastodon").enqueue(
|
|
||||||
refresh_mastodon_data_task, current_user.pk, token
|
|
||||||
)
|
|
||||||
messages.add_message(
|
|
||||||
request,
|
|
||||||
messages.INFO,
|
|
||||||
_("Login information updated.") + account.handle,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
messages.add_message(
|
|
||||||
request, messages.ERROR, _("Invalid account data from Fediverse instance.")
|
|
||||||
)
|
|
||||||
return redirect(reverse("users:info"))
|
|
||||||
|
|
||||||
|
|
||||||
def clear_preference_cache(request):
|
|
||||||
for key in list(request.session.keys()):
|
|
||||||
if key.startswith("p_"):
|
|
||||||
del request.session[key]
|
|
||||||
|
|
||||||
|
|
||||||
def auth_login(request, user):
|
|
||||||
auth.login(request, user, backend="mastodon.auth.OAuth2Backend")
|
|
||||||
request.session.pop("verified_account", None)
|
|
||||||
clear_preference_cache(request)
|
|
||||||
if (
|
|
||||||
user.mastodon_last_refresh < timezone.now() - timedelta(hours=1)
|
|
||||||
or user.mastodon_account == {}
|
|
||||||
):
|
|
||||||
django_rq.get_queue("mastodon").enqueue(refresh_mastodon_data_task, user.pk)
|
|
||||||
|
|
||||||
|
|
||||||
def auth_logout(request):
|
|
||||||
auth.logout(request)
|
|
||||||
response = redirect("/")
|
|
||||||
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def clear_data_task(user_id):
|
|
||||||
user = User.objects.get(pk=user_id)
|
|
||||||
user_str = str(user)
|
|
||||||
if user.identity:
|
|
||||||
remove_data_by_user(user.identity)
|
|
||||||
Takahe.delete_identity(user.identity.pk)
|
|
||||||
user.clear()
|
|
||||||
logger.warning(f"User {user_str} data cleared.")
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def clear_data(request):
|
|
||||||
if request.META.get("HTTP_AUTHORIZATION"):
|
|
||||||
raise BadRequest("Only for web login")
|
|
||||||
if request.method == "POST":
|
|
||||||
v = request.POST.get("verification")
|
|
||||||
if v and (v == request.user.mastodon_acct or v == request.user.email):
|
|
||||||
django_rq.get_queue("mastodon").enqueue(clear_data_task, request.user.id)
|
|
||||||
return auth_logout(request)
|
|
||||||
else:
|
|
||||||
messages.add_message(request, messages.ERROR, _("Account mismatch."))
|
|
||||||
return redirect(reverse("users:data"))
|
|
|
@ -1,12 +1,11 @@
|
||||||
from ninja import Schema
|
from ninja import Schema
|
||||||
from ninja.security import django_auth
|
|
||||||
|
|
||||||
from common.api import *
|
from common.api import *
|
||||||
|
|
||||||
|
|
||||||
class UserSchema(Schema):
|
class UserSchema(Schema):
|
||||||
url: str
|
url: str
|
||||||
external_acct: str
|
external_acct: str | None
|
||||||
display_name: str
|
display_name: str
|
||||||
avatar: str
|
avatar: str
|
||||||
username: str
|
username: str
|
||||||
|
@ -22,7 +21,9 @@ def me(request):
|
||||||
return 200, {
|
return 200, {
|
||||||
"username": request.user.username,
|
"username": request.user.username,
|
||||||
"url": settings.SITE_INFO["site_url"] + request.user.url,
|
"url": settings.SITE_INFO["site_url"] + request.user.url,
|
||||||
"external_acct": request.user.mastodon_acct,
|
"external_acct": (
|
||||||
|
request.user.mastodon.handle if request.user.mastodon else None
|
||||||
|
),
|
||||||
"display_name": request.user.display_name,
|
"display_name": request.user.display_name,
|
||||||
"avatar": request.user.avatar,
|
"avatar": request.user.avatar,
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,9 @@ class Command(BaseCommand):
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
m = 0
|
m = 0
|
||||||
e = 0
|
e = 0
|
||||||
|
qs = User.objects.filter(username__isnull=True)
|
||||||
|
print(f"Deleting {qs.count()} nameless users.")
|
||||||
|
qs.delete()
|
||||||
for user in tqdm(User.objects.filter(is_active=True)):
|
for user in tqdm(User.objects.filter(is_active=True)):
|
||||||
if user.mastodon_username:
|
if user.mastodon_username:
|
||||||
MastodonAccount.objects.update_or_create(
|
MastodonAccount.objects.update_or_create(
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Generated by Django 4.2.13 on 2024-07-02 18:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0003_remove_preference_no_anonymous_view"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="user",
|
||||||
|
name="at_least_one_login_method",
|
||||||
|
),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="user",
|
||||||
|
name="unique_email",
|
||||||
|
),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="user",
|
||||||
|
name="unique_mastodon_username",
|
||||||
|
),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="user",
|
||||||
|
name="unique_mastodon_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name="user",
|
||||||
|
name="users_user_mastodo_bd2db5_idx",
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(models.F("is_active"), name="index_user_is_active"),
|
||||||
|
),
|
||||||
|
]
|
|
@ -10,20 +10,23 @@ from django.contrib.auth.validators import UnicodeUsernameValidator
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import F, Manager, Q, Value
|
from django.db.models.functions import Lower
|
||||||
from django.db.models.functions import Concat, Lower
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone, translation
|
from django.utils import timezone, translation
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from mastodon.models import EmailAccount, MastodonAccount, Platform, SocialAccount
|
from mastodon.models import (
|
||||||
|
BlueskyAccount,
|
||||||
|
EmailAccount,
|
||||||
|
MastodonAccount,
|
||||||
|
SocialAccount,
|
||||||
|
ThreadsAccount,
|
||||||
|
)
|
||||||
from takahe.utils import Takahe
|
from takahe.utils import Takahe
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mastodon.models import Mastodon
|
|
||||||
|
|
||||||
from .apidentity import APIdentity
|
from .apidentity import APIdentity
|
||||||
from .preference import Preference
|
from .preference import Preference
|
||||||
|
|
||||||
|
@ -59,18 +62,31 @@ class UsernameValidator(UnicodeUsernameValidator):
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
def create_user(self, username, email, password=None):
|
def create_user(self, username, email, password=None):
|
||||||
|
from mastodon.models import Email
|
||||||
|
|
||||||
Takahe.get_domain() # ensure configuration is complete
|
Takahe.get_domain() # ensure configuration is complete
|
||||||
user = User.register(username=username, email=email)
|
|
||||||
|
user = User.register(username=username)
|
||||||
|
e = Email.new_account(email)
|
||||||
|
if not e:
|
||||||
|
raise ValueError("Invalid Email")
|
||||||
|
e.user = user
|
||||||
|
e.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_superuser(self, username, email, password=None):
|
def create_superuser(self, username, email, password=None):
|
||||||
|
from mastodon.models import Email
|
||||||
from takahe.models import User as TakaheUser
|
from takahe.models import User as TakaheUser
|
||||||
|
|
||||||
Takahe.get_domain() # ensure configuration is complete
|
Takahe.get_domain() # ensure configuration is complete
|
||||||
user = User.register(username=username, email=email, is_superuser=True)
|
user = User.register(username=username, is_superuser=True)
|
||||||
|
e = Email.new_account(email)
|
||||||
|
if not e:
|
||||||
|
raise ValueError("Invalid Email")
|
||||||
|
e.user = user
|
||||||
|
e.save()
|
||||||
tu = TakaheUser.objects.get(pk=user.pk, email="@" + username)
|
tu = TakaheUser.objects.get(pk=user.pk, email="@" + username)
|
||||||
tu.admin = True
|
tu.admin = True
|
||||||
tu.set_password(password)
|
|
||||||
tu.save()
|
tu.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@ -159,37 +175,21 @@ class User(AbstractUser):
|
||||||
Lower("username"),
|
Lower("username"),
|
||||||
name="unique_username",
|
name="unique_username",
|
||||||
),
|
),
|
||||||
models.UniqueConstraint(
|
|
||||||
Lower("email"),
|
|
||||||
name="unique_email",
|
|
||||||
),
|
|
||||||
models.UniqueConstraint(
|
|
||||||
Lower("mastodon_username"),
|
|
||||||
Lower("mastodon_site"),
|
|
||||||
name="unique_mastodon_username",
|
|
||||||
),
|
|
||||||
models.UniqueConstraint(
|
|
||||||
Lower("mastodon_id"),
|
|
||||||
Lower("mastodon_site"),
|
|
||||||
name="unique_mastodon_id",
|
|
||||||
),
|
|
||||||
models.CheckConstraint(
|
|
||||||
check=(
|
|
||||||
Q(is_active=False)
|
|
||||||
| Q(mastodon_username__isnull=False)
|
|
||||||
| Q(email__isnull=False)
|
|
||||||
),
|
|
||||||
name="at_least_one_login_method",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["mastodon_site", "mastodon_username"]),
|
|
||||||
]
|
]
|
||||||
|
indexes = [models.Index("is_active", name="index_user_is_active")]
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def mastodon(self) -> "MastodonAccount | None":
|
def mastodon(self) -> "MastodonAccount | None":
|
||||||
return MastodonAccount.objects.filter(user=self).first()
|
return MastodonAccount.objects.filter(user=self).first()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def threads(self) -> "ThreadsAccount | None":
|
||||||
|
return ThreadsAccount.objects.filter(user=self).first()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def bluesky(self) -> "BlueskyAccount | None":
|
||||||
|
return BlueskyAccount.objects.filter(user=self).first()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def email_account(self) -> "EmailAccount | None":
|
def email_account(self) -> "EmailAccount | None":
|
||||||
return EmailAccount.objects.filter(user=self).first()
|
return EmailAccount.objects.filter(user=self).first()
|
||||||
|
@ -239,34 +239,21 @@ class User(AbstractUser):
|
||||||
return p.edited_time if p else None
|
return p.edited_time if p else None
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
if self.mastodon_site == "removed" and not self.is_active:
|
if not self.is_active:
|
||||||
return
|
return
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
self.first_name = self.mastodon_acct or ""
|
accts = [str(a) for a in self.social_accounts.all()]
|
||||||
self.last_name = self.email or ""
|
self.first_name = (";").join(accts)
|
||||||
|
self.last_name = self.username
|
||||||
self.is_active = False
|
self.is_active = False
|
||||||
self.email = None
|
|
||||||
# self.username = "~removed~" + str(self.pk)
|
# self.username = "~removed~" + str(self.pk)
|
||||||
# to get ready for federation, username has to be reserved
|
# to get ready for federation, username has to be reserved
|
||||||
self.mastodon_username = None
|
|
||||||
self.mastodon_id = None
|
|
||||||
self.mastodon_site = "removed"
|
|
||||||
self.mastodon_token = ""
|
|
||||||
self.mastodon_locked = False
|
|
||||||
self.mastodon_followers = []
|
|
||||||
self.mastodon_following = []
|
|
||||||
self.mastodon_mutes = []
|
|
||||||
self.mastodon_blocks = []
|
|
||||||
self.mastodon_domain_blocks = []
|
|
||||||
self.mastodon_account = {}
|
|
||||||
self.save()
|
self.save()
|
||||||
self.identity.deleted = timezone.now()
|
self.identity.deleted = timezone.now()
|
||||||
self.identity.save()
|
self.identity.save()
|
||||||
SocialAccount.objects.filter(user=self).delete()
|
self.social_accounts.all().delete()
|
||||||
|
|
||||||
def sync_relationship(self):
|
def sync_relationship(self):
|
||||||
from .apidentity import APIdentity
|
|
||||||
|
|
||||||
def get_identity_ids(accts: list):
|
def get_identity_ids(accts: list):
|
||||||
return set(
|
return set(
|
||||||
MastodonAccount.objects.filter(handle__in=accts).values_list(
|
MastodonAccount.objects.filter(handle__in=accts).values_list(
|
||||||
|
@ -301,29 +288,59 @@ class User(AbstractUser):
|
||||||
Takahe.mute(me, target_identity)
|
Takahe.mute(me, target_identity)
|
||||||
|
|
||||||
def sync_identity(self):
|
def sync_identity(self):
|
||||||
|
"""sync display name, bio, and avatar from available sources"""
|
||||||
identity = self.identity.takahe_identity
|
identity = self.identity.takahe_identity
|
||||||
if identity.deleted:
|
if identity.deleted:
|
||||||
logger.error(f"Identity {identity} is deleted, skip sync")
|
logger.error(f"Identity {identity} is deleted, skip sync")
|
||||||
return
|
return
|
||||||
mastodon = self.mastodon
|
mastodon = self.mastodon
|
||||||
if not mastodon:
|
threads = self.threads
|
||||||
return
|
# bluesky = self.bluesky
|
||||||
identity.name = mastodon.display_name or identity.name or identity.username
|
changed = False
|
||||||
identity.summary = mastodon.note or identity.summary
|
name = (
|
||||||
identity.manually_approves_followers = mastodon.locked
|
(mastodon.display_name if mastodon else "")
|
||||||
if not bool(identity.icon) or identity.icon_uri != mastodon.avatar:
|
or (threads.username if threads else "")
|
||||||
identity.icon_uri = mastodon.avatar
|
# or (bluesky.display_name if bluesky else "")
|
||||||
if identity.icon_uri:
|
or identity.name
|
||||||
|
or identity.username
|
||||||
|
)
|
||||||
|
if identity.name != name:
|
||||||
|
identity.name = name
|
||||||
|
changed = True
|
||||||
|
summary = (
|
||||||
|
(mastodon.note if mastodon else "")
|
||||||
|
or (threads.threads_biography if threads else "")
|
||||||
|
# or (bluesky.note if bluesky else "")
|
||||||
|
or identity.summary
|
||||||
|
)
|
||||||
|
if identity.summary != summary:
|
||||||
|
identity.summary = summary
|
||||||
|
changed = True
|
||||||
|
identity.manually_approves_followers = (
|
||||||
|
mastodon.locked if mastodon else identity.manually_approves_followers
|
||||||
|
)
|
||||||
|
# it's tedious to update avatar repeatedly, so only sync it once
|
||||||
|
if not identity.icon:
|
||||||
|
url = None
|
||||||
|
if mastodon and mastodon.avatar:
|
||||||
|
url = mastodon.avatar
|
||||||
|
elif threads and threads.threads_profile_picture_url:
|
||||||
|
url = threads.threads_profile_picture_url
|
||||||
|
# elif bluesky and bluesky.
|
||||||
|
if url:
|
||||||
try:
|
try:
|
||||||
r = httpx.get(identity.icon_uri)
|
r = httpx.get(url)
|
||||||
f = ContentFile(r.content, name=identity.icon_uri.split("/")[-1])
|
f = ContentFile(r.content, name=identity.icon_uri.split("/")[-1])
|
||||||
identity.icon.save(f.name, f, save=False)
|
identity.icon.save(f.name, f, save=False)
|
||||||
|
changed = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"fetch icon failed: {identity} {identity.icon_uri}",
|
f"fetch icon failed: {identity} {identity.icon_uri}",
|
||||||
extra={"exception": e},
|
extra={"exception": e},
|
||||||
)
|
)
|
||||||
identity.save()
|
if changed:
|
||||||
|
identity.save()
|
||||||
|
Takahe.update_state(identity, "outdated")
|
||||||
|
|
||||||
def refresh_mastodon_data(self, skip_detail=False, sleep_hours=0):
|
def refresh_mastodon_data(self, skip_detail=False, sleep_hours=0):
|
||||||
"""Try refresh account data from mastodon server, return True if refreshed successfully"""
|
"""Try refresh account data from mastodon server, return True if refreshed successfully"""
|
||||||
|
@ -387,18 +404,12 @@ class User(AbstractUser):
|
||||||
|
|
||||||
account = param.pop("account", None)
|
account = param.pop("account", None)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if account:
|
|
||||||
if account.platform == Platform.MASTODON:
|
|
||||||
param["mastodon_username"] = account.account_data["username"]
|
|
||||||
param["mastodon_site"] = account.domain
|
|
||||||
param["mastodon_id"] = account.account_data["id"]
|
|
||||||
elif account.platform == Platform.EMAIL:
|
|
||||||
param["email"] = account.handle
|
|
||||||
new_user = cls(**param)
|
new_user = cls(**param)
|
||||||
if not new_user.username:
|
if not new_user.username:
|
||||||
raise ValueError("username is not set")
|
raise ValueError("username is not set")
|
||||||
if "language" not in param:
|
if "language" not in param:
|
||||||
new_user.language = translation.get_language()
|
new_user.language = translation.get_language()
|
||||||
|
new_user.set_unusable_password()
|
||||||
new_user.save()
|
new_user.save()
|
||||||
Preference.objects.create(user=new_user)
|
Preference.objects.create(user=new_user)
|
||||||
if account:
|
if account:
|
||||||
|
@ -406,10 +417,14 @@ class User(AbstractUser):
|
||||||
account.save()
|
account.save()
|
||||||
Takahe.init_identity_for_local_user(new_user)
|
Takahe.init_identity_for_local_user(new_user)
|
||||||
new_user.identity.shelf_manager
|
new_user.identity.shelf_manager
|
||||||
if new_user.mastodon:
|
|
||||||
Takahe.fetch_remote_identity(new_user.mastodon.handle)
|
|
||||||
return new_user
|
return new_user
|
||||||
|
|
||||||
|
def reconnect_account(self, account: SocialAccount):
|
||||||
|
with transaction.atomic():
|
||||||
|
SocialAccount.objects.filter(user=self, type=account.type).delete()
|
||||||
|
account.user = self
|
||||||
|
account.save()
|
||||||
|
|
||||||
|
|
||||||
# TODO the following models should be deprecated soon
|
# TODO the following models should be deprecated soon
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
{% if allow_any_site %}
|
{% if allow_any_site %}
|
||||||
<article>
|
<article>
|
||||||
<details>
|
<details>
|
||||||
<summary>{% trans 'Username, Email and Identities' %}</summary>
|
<summary>{% trans 'Username and Email' %}</summary>
|
||||||
<form action="{% url 'users:register' %}?next={{ request.path }}"
|
<form action="{% url 'users:register' %}?next={{ request.path }}"
|
||||||
method="post">
|
method="post">
|
||||||
<small>{{ error }}</small>
|
<small>{{ error }}</small>
|
||||||
|
@ -46,16 +46,26 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="submit" value="{% trans 'Save' %}" disabled id="save">
|
<input type="submit" value="{% trans 'Save' %}" disabled id="save">
|
||||||
</form>
|
</form>
|
||||||
<form action="{% url 'users:reconnect' %}" method="post">
|
</details>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<details>
|
||||||
|
<summary>{% trans "Fediverse (Mastodon)" %}</summary>
|
||||||
|
<form action="{% url 'mastodon:reconnect' %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{% if request.user.mastodon_acct %}
|
{% if request.user.mastodon %}
|
||||||
<label>
|
<label>
|
||||||
{% trans "Associated identities" %}
|
<i class="fa-brands fa-mastodon"></i> {% trans "Verified Identity" %}
|
||||||
<input type="input"
|
<input type="input"
|
||||||
{% if request.user.mastodon_acct %}aria-invalid="false"{% endif %}
|
{% if request.user.mastodon %}aria-invalid="false"{% endif %}
|
||||||
value="{{ request.user.mastodon_acct | default:'-' }}"
|
value="{{ request.user.mastodon.handle | default:'-' }}"
|
||||||
readonly>
|
readonly>
|
||||||
|
<small>
|
||||||
|
{% if request.user.mastodon.last_refresh %}
|
||||||
|
{% trans "Last updated" %} {{ request.user.mastodon.last_refresh }}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
|
@ -63,7 +73,7 @@
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<label>
|
<label>
|
||||||
{% if request.user.mastodon_acct %}
|
{% if request.user.mastodon %}
|
||||||
{% trans "To associate with another federated identity, please enter the domain name of the instance where the new identity is located." %}
|
{% trans "To associate with another federated identity, please enter the domain name of the instance where the new identity is located." %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "If you have registered with a Federated instance, please enter the instance domain name." %}
|
{% trans "If you have registered with a Federated instance, please enter the instance domain name." %}
|
||||||
|
@ -79,7 +89,7 @@
|
||||||
disabled
|
disabled
|
||||||
id="bind" />
|
id="bind" />
|
||||||
<small>
|
<small>
|
||||||
{% if request.user.mastodon_acct %}
|
{% if request.user.mastodon %}
|
||||||
{% blocktrans %}After replacing the association, you may use the new Fediverse identity to log in and control data visibility. Existing data such as tags, comments, and collections will not be affected.{% endblocktrans %}
|
{% blocktrans %}After replacing the association, you may use the new Fediverse identity to log in and control data visibility. Existing data such as tags, comments, and collections will not be affected.{% endblocktrans %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "Once associated with Fediverse identity, you can discover more users and use the full features of this site." %}
|
{% trans "Once associated with Fediverse identity, you can discover more users and use the full features of this site." %}
|
||||||
|
@ -89,13 +99,39 @@
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
</article>
|
</article>
|
||||||
|
<article>
|
||||||
|
<details>
|
||||||
|
<summary>{% trans "Threads.net" %}</summary>
|
||||||
|
<form action="{% url 'mastodon:threads_reconnect' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<fieldset>
|
||||||
|
{% if request.user.threads %}
|
||||||
|
<label>
|
||||||
|
<i class="fa-brands fa-threads"></i> {% trans "Verified threads.net account" %}
|
||||||
|
<input type="input"
|
||||||
|
aria-invalid="false"
|
||||||
|
value="{{ request.user.threads.handle }}"
|
||||||
|
readonly>
|
||||||
|
<small>
|
||||||
|
{% if request.user.threads.last_refresh %}
|
||||||
|
{% trans "Last updated" %} {{ request.user.threads.last_refresh }}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<input type="submit"
|
||||||
|
{% if request.user.threads %} value="{% trans 'Link with a different threads.net account' %}" {% else %} {% trans "Link with a threads.net account" %} {% endif %} />
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<article>
|
<article>
|
||||||
<details>
|
<details>
|
||||||
<summary>{% trans "Display name, avatar and other information" %}</summary>
|
<summary>{% trans "Display name, avatar and other information" %}</summary>
|
||||||
<form action="{% url 'users:profile' %}?next={{ request.path }}"
|
<form action="{% url 'users:profile' %}?next={{ request.path }}"
|
||||||
method="post"
|
method="post"
|
||||||
{% if request.user.mastodon_acct and not request.user.preference.mastodon_skip_userinfo %}onsubmit="return confirm('{% trans "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?" %}')"{% endif %}
|
{% if request.user.mastodon and not request.user.preference.mastodon_skip_userinfo %}onsubmit="return confirm('{% trans "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?" %}')"{% endif %}
|
||||||
enctype="multipart/form-data">
|
enctype="multipart/form-data">
|
||||||
{% include "_field.html" with field=profile_form.name %}
|
{% include "_field.html" with field=profile_form.name %}
|
||||||
{% include "_field.html" with field=profile_form.summary %}
|
{% include "_field.html" with field=profile_form.summary %}
|
||||||
|
@ -176,45 +212,47 @@
|
||||||
value="{% trans 'Sync now' %}"
|
value="{% trans 'Sync now' %}"
|
||||||
{% if not request.user.mastodon_username %}disabled{% endif %} />
|
{% if not request.user.mastodon_username %}disabled{% endif %} />
|
||||||
<small>
|
<small>
|
||||||
{% if request.user.mastodon_last_refresh %}
|
{% if request.user.mastodon.last_refresh %}
|
||||||
{% trans "Last updated" %} {{ request.user.mastodon_last_refresh }}
|
{% trans "Last updated" %} {{ request.user.mastodon.last_refresh }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</small>
|
</small>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
{% if allow_any_site %}
|
||||||
<details>
|
<article>
|
||||||
<summary>{% trans 'Delete Account' %}</summary>
|
<details>
|
||||||
<form action="{% url 'users:clear_data' %}"
|
<summary>{% trans 'Delete Account' %}</summary>
|
||||||
method="post"
|
<form action="{% url 'users:clear_data' %}"
|
||||||
onsubmit="return confirm('{% trans "Once deleted, account data cannot be recovered. Sure to proceed?" %}');">
|
method="post"
|
||||||
{% csrf_token %}
|
onsubmit="return confirm('{% trans "Once deleted, account data cannot be recovered. Sure to proceed?" %}');">
|
||||||
<div>
|
{% csrf_token %}
|
||||||
{% blocktrans %}Enter full <code>username@instance.social</code> or <code>email@domain.com</code> to confirm deletion.{% endblocktrans %}
|
<div>
|
||||||
<input type="email"
|
{% blocktrans %}Enter full <code>username@instance.social</code> or <code>email@domain.com</code> to confirm deletion.{% endblocktrans %}
|
||||||
name="verification"
|
<input type="email"
|
||||||
_="on input remove [@disabled] from #delete end"
|
name="verification"
|
||||||
value=""
|
_="on input remove [@disabled] from #delete end"
|
||||||
autocomplete="off"
|
value=""
|
||||||
required
|
autocomplete="off"
|
||||||
aria-invalid="true"
|
required
|
||||||
aria-describedby="invalid-helper"
|
aria-invalid="true"
|
||||||
placeholder="Gargron@mastodon.social">
|
aria-describedby="invalid-helper"
|
||||||
<small id="invalid-helper">{% trans "Once deleted, account data cannot be recovered." %}</small>
|
placeholder="Gargron@mastodon.social">
|
||||||
{% if import_status.douban_pending %}
|
<small id="invalid-helper">{% trans "Once deleted, account data cannot be recovered." %}</small>
|
||||||
<input type="submit" value="{% trans "Importing in progress, can't delete now." %}" disabled />
|
{% if import_status.douban_pending %}
|
||||||
{% else %}
|
<input type="submit" value="{% trans "Importing in progress, can't delete now." %}" disabled />
|
||||||
<input type="submit"
|
{% else %}
|
||||||
value="{% trans 'Permanently Delete' %}"
|
<input type="submit"
|
||||||
class="contrast"
|
value="{% trans 'Permanently Delete' %}"
|
||||||
disabled
|
class="contrast"
|
||||||
id="delete" />
|
disabled
|
||||||
{% endif %}
|
id="delete" />
|
||||||
</div>
|
{% endif %}
|
||||||
</form>
|
</div>
|
||||||
</details>
|
</form>
|
||||||
</article>
|
</details>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include "_sidebar.html" with show_profile=1 identity=request.user.identity %}
|
{% include "_sidebar.html" with show_profile=1 identity=request.user.identity %}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -12,10 +12,8 @@
|
||||||
<meta property="og:image" content="{{ identity.avatar }}">
|
<meta property="og:image" content="{{ identity.avatar }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% if identity.local and identity.user.mastodon_account.url %}
|
{% if identity.local and identity.user.mastodon %}
|
||||||
<a href="{{ identity.user.mastodon_account.url }}"
|
<a href="{{ identity.user.mastodon.url }}" rel="me" style="display:none">Mastodon verification</a>
|
||||||
rel="me"
|
|
||||||
style="display:none">Mastodon verification</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -70,22 +70,24 @@
|
||||||
</svg>
|
</svg>
|
||||||
<!--<i class="fa-brands fa-mastodon"></i>-->
|
<!--<i class="fa-brands fa-mastodon"></i>-->
|
||||||
</button>
|
</button>
|
||||||
<!--
|
|
||||||
{% if enable_threads %}
|
{% if enable_threads %}
|
||||||
<button class="platform outline" _="on click add .outline to .platform then remove .outline from me then hide <form/> then show #login-threads" id="platform-threads" title="{% trans "Threads" %}">
|
<button class="platform outline" _="on click add .outline to .platform then remove .outline from me then hide
|
||||||
<i class="fa-brands fa-threads"></i>
|
<form/>
|
||||||
</button>
|
then show #login-threads" id="platform-threads" title="{% trans "Threads" %}">
|
||||||
{%endif%}
|
<i class="fa-brands fa-threads"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if enable_bluesky %}
|
{% if enable_bluesky %}
|
||||||
<button class="platform outline" _="on click add .outline to .platform then remove .outline from me then hide <form/> then show #login-bluesky" id="platform-bluesky" title="{% trans "Bluesky" %}">
|
<button class="platform outline" _="on click add .outline to .platform then remove .outline from me then hide
|
||||||
<i class="fa-brands fa-bluesky" style="font-size:85%"></i>
|
<form/>
|
||||||
</button>
|
then show #login-bluesky" id="platform-bluesky" title="{% trans "Bluesky" %}">
|
||||||
{%endif%}
|
<i class="fa-brands fa-bluesky" style="font-size:85%"></i>
|
||||||
-->
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<form id="login-email"
|
<form id="login-email"
|
||||||
style="display:none"
|
style="display:none"
|
||||||
action="{% url 'users:connect' %}"
|
action="{% url 'mastodon:email_login' %}"
|
||||||
method="post"
|
method="post"
|
||||||
onsubmit="return login(this);">
|
onsubmit="return login(this);">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
@ -100,7 +102,7 @@
|
||||||
</form>
|
</form>
|
||||||
<form id="login-mastodon"
|
<form id="login-mastodon"
|
||||||
style="display:none"
|
style="display:none"
|
||||||
action="{% url 'users:connect' %}"
|
action="{% url 'mastodon:login' %}"
|
||||||
method="post"
|
method="post"
|
||||||
onsubmit="return login(this);">
|
onsubmit="return login(this);">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
@ -127,7 +129,7 @@
|
||||||
</form>
|
</form>
|
||||||
<form id="login-threads"
|
<form id="login-threads"
|
||||||
style="display:none"
|
style="display:none"
|
||||||
action="{% url 'users:connect' %}"
|
action="{% url 'mastodon:threads_login' %}"
|
||||||
method="post"
|
method="post"
|
||||||
onsubmit="return login(this);">
|
onsubmit="return login(this);">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
@ -137,7 +139,7 @@
|
||||||
</form>
|
</form>
|
||||||
<form id="login-bluesky"
|
<form id="login-bluesky"
|
||||||
style="display:none"
|
style="display:none"
|
||||||
action="{% url 'users:connect' %}"
|
action="{% url 'mastodon:login' %}"
|
||||||
method="post"
|
method="post"
|
||||||
onsubmit="return login(this);">
|
onsubmit="return login(this);">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
|
@ -90,7 +90,7 @@
|
||||||
<label for="post_public_mode_4">{% trans "local, this site only" %}</label>
|
<label for="post_public_mode_4">{% trans "local, this site only" %}</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% if request.user.mastodon_acct %}
|
{% if request.user.mastodon %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label>
|
<label>
|
||||||
{% trans "Turn on crosspost to timeline by default" %}
|
{% trans "Turn on crosspost to timeline by default" %}
|
||||||
|
|
|
@ -54,17 +54,27 @@
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if identity.user.mastodon_account %}
|
{% if not identity.locked or request.user.is_superuser or relationship.requested or relationship.status %}
|
||||||
{% if not identity.locked or request.user.is_superuser or relationship.requested or relationship.status %}
|
{% if identity.user.mastodon %}
|
||||||
<span>
|
<span>
|
||||||
<a href="{{ identity.user.mastodon_account.url }}"
|
<a href="{{ identity.user.mastodon.url }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
title="@{{ identity.user.mastodon_acct }}">
|
title="@{{ identity.user.mastodon.handle }}">
|
||||||
<i class="fa-brands fa-mastodon"></i>
|
<i class="fa-brands fa-mastodon"></i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if identity.user.threads %}
|
||||||
|
<span>
|
||||||
|
<a href="{{ identity.user.threads.url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
title="@{{ identity.user.threads.handle }}">
|
||||||
|
<i class="fa-brands fa-threads"></i>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if relationship.requested %}
|
{% if relationship.requested %}
|
||||||
|
|
|
@ -12,7 +12,10 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<article>
|
<article>
|
||||||
<header style="text-align: center;">
|
<header style="text-align: center;">
|
||||||
<img src="{{ site_logo }}" class="logo" alt="logo">
|
<img src="{{ site_logo }}"
|
||||||
|
class="logo"
|
||||||
|
alt="logo"
|
||||||
|
style="max-height: 42vh">
|
||||||
</header>
|
</header>
|
||||||
{% if form %}
|
{% if form %}
|
||||||
<form action="{% url 'users:register' %}" method="post">
|
<form action="{% url 'users:register' %}" method="post">
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<form action="{% url 'users:verify_code' %}" method="post">
|
<form action="{% url 'mastodon:email_verify' %}" method="post">
|
||||||
<div class="otp">
|
<div class="otp">
|
||||||
<input name="code"
|
<input name="code"
|
||||||
maxlength="5"
|
maxlength="5"
|
||||||
|
|
|
@ -12,7 +12,10 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<article>
|
<article>
|
||||||
<header style="text-align: center;">
|
<header style="text-align: center;">
|
||||||
<img src="{{ site_logo }}" class="logo" alt="logo">
|
<img src="{{ site_logo }}"
|
||||||
|
class="logo"
|
||||||
|
alt="logo"
|
||||||
|
style="max-height: 42vh">
|
||||||
</header>
|
</header>
|
||||||
<h4>{% trans "Welcome" %}</h4>
|
<h4>{% trans "Welcome" %}</h4>
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -11,15 +11,11 @@ class UserTest(TestCase):
|
||||||
databases = "__all__"
|
databases = "__all__"
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.alice = User.register(
|
self.alice = User.register(username="alice").identity
|
||||||
mastodon_site="MySpace", mastodon_username="Alice", username="alice"
|
|
||||||
).identity
|
|
||||||
MastodonAccount.objects.create(
|
MastodonAccount.objects.create(
|
||||||
handle="Alice@MySpace", user=self.alice.user, domain="MySpace", uid="42"
|
handle="Alice@MySpace", user=self.alice.user, domain="MySpace", uid="42"
|
||||||
)
|
)
|
||||||
self.bob = User.register(
|
self.bob = User.register(username="bob").identity
|
||||||
mastodon_site="KKCity", mastodon_username="Bob", username="bob"
|
|
||||||
).identity
|
|
||||||
self.domain = settings.SITE_INFO.get("site_domain")
|
self.domain = settings.SITE_INFO.get("site_domain")
|
||||||
|
|
||||||
def test_handle(self):
|
def test_handle(self):
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .account import *
|
|
||||||
from .profile import *
|
|
||||||
from .views import *
|
from .views import *
|
||||||
|
|
||||||
app_name = "users"
|
app_name = "users"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("login", login, name="login"),
|
path("login", login, name="login"),
|
||||||
path("login/oauth", connect_redirect_back, name="login_oauth"),
|
|
||||||
path("verify_code", verify_code, name="verify_code"),
|
|
||||||
path("register", register, name="register"),
|
path("register", register, name="register"),
|
||||||
path("connect", connect, name="connect"),
|
|
||||||
path("reconnect", reconnect, name="reconnect"),
|
|
||||||
path("fetch_refresh", fetch_refresh, name="fetch_refresh"),
|
path("fetch_refresh", fetch_refresh, name="fetch_refresh"),
|
||||||
path("data", data, name="data"),
|
path("data", data, name="data"),
|
||||||
path("info", account_info, name="info"),
|
path("info", account_info, name="info"),
|
||||||
|
|
4
users/views/__init__.py
Normal file
4
users/views/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from .account import *
|
||||||
|
from .actions import *
|
||||||
|
from .data import *
|
||||||
|
from .profile import *
|
242
users/views/account.py
Normal file
242
users/views/account.py
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import django_rq
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import auth, messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import BadRequest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from common.utils import AuthedHttpRequest
|
||||||
|
from journal.models import remove_data_by_user
|
||||||
|
from mastodon.models import Email, Mastodon
|
||||||
|
from mastodon.models.common import Platform, SocialAccount
|
||||||
|
from mastodon.models.email import EmailAccount
|
||||||
|
from takahe.utils import Takahe
|
||||||
|
|
||||||
|
from ..models import User
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def login(request):
|
||||||
|
"""show login page"""
|
||||||
|
selected_domain = request.GET.get("domain", default="")
|
||||||
|
sites = Mastodon.get_sites()
|
||||||
|
if request.GET.get("next"):
|
||||||
|
request.session["next_url"] = request.GET.get("next")
|
||||||
|
invite_status = -1 if settings.INVITE_ONLY else 0
|
||||||
|
if settings.INVITE_ONLY and request.GET.get("invite"):
|
||||||
|
if Takahe.verify_invite(request.GET.get("invite")):
|
||||||
|
invite_status = 1
|
||||||
|
request.session["invite"] = request.GET.get("invite")
|
||||||
|
else:
|
||||||
|
invite_status = -2
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"users/login.html",
|
||||||
|
{
|
||||||
|
"sites": sites,
|
||||||
|
"scope": quote(settings.MASTODON_CLIENT_SCOPE),
|
||||||
|
"selected_domain": selected_domain,
|
||||||
|
"allow_any_site": settings.MASTODON_ALLOW_ANY_SITE,
|
||||||
|
"enable_email": settings.ENABLE_LOGIN_EMAIL,
|
||||||
|
"enable_threads": bool(settings.THREADS_APP_ID),
|
||||||
|
"enable_bluesky": settings.BLUESKY_LOGIN_ENABLED,
|
||||||
|
"invite_status": invite_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def logout(request):
|
||||||
|
return auth_logout(request)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationForm(forms.ModelForm):
|
||||||
|
email = forms.EmailField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ["username"]
|
||||||
|
|
||||||
|
def clean_username(self):
|
||||||
|
username = self.cleaned_data.get("username")
|
||||||
|
if username and self.instance and self.instance.username:
|
||||||
|
username = self.instance.username
|
||||||
|
elif (
|
||||||
|
username
|
||||||
|
and User.objects.filter(username__iexact=username)
|
||||||
|
.exclude(pk=self.instance.pk if self.instance else -1)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
|
raise forms.ValidationError(_("This username is already in use."))
|
||||||
|
return username
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
email = self.cleaned_data.get("email", "").strip()
|
||||||
|
if (
|
||||||
|
email
|
||||||
|
and EmailAccount.objects.filter(handle__iexact=email)
|
||||||
|
.exclude(user_id=self.instance.pk if self.instance else -1)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
|
raise forms.ValidationError(_("This email address is already in use."))
|
||||||
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def register(request: AuthedHttpRequest):
|
||||||
|
"""show registration page and process the submission from it"""
|
||||||
|
|
||||||
|
# check invite code if invite-only
|
||||||
|
if settings.INVITE_ONLY and not request.user.is_authenticated:
|
||||||
|
if not Takahe.verify_invite(str(request.session.get("invite"))):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"common/error.html",
|
||||||
|
{
|
||||||
|
"msg": _("Authentication failed"),
|
||||||
|
"secondary_msg": _("Registration is for invitation only"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = request.POST.copy()
|
||||||
|
error = None
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
# logged in user to change email
|
||||||
|
verified_account = None
|
||||||
|
else:
|
||||||
|
verified_account = SocialAccount.from_dict(
|
||||||
|
request.session.get("verified_account")
|
||||||
|
)
|
||||||
|
if not verified_account:
|
||||||
|
# kick back to login if no identity verified
|
||||||
|
return redirect(reverse("users:login"))
|
||||||
|
|
||||||
|
# no registration form for closed community mode
|
||||||
|
if not settings.MASTODON_ALLOW_ANY_SITE:
|
||||||
|
if verified_account and verified_account.platform == Platform.MASTODON:
|
||||||
|
# directly create a new user
|
||||||
|
new_user = User.register(
|
||||||
|
account=verified_account,
|
||||||
|
username=verified_account.username, # type:ignore
|
||||||
|
)
|
||||||
|
auth_login(request, new_user)
|
||||||
|
return render(request, "users/welcome.html")
|
||||||
|
else:
|
||||||
|
return redirect(reverse("common:home"))
|
||||||
|
|
||||||
|
# use verified email if presents for new account creation
|
||||||
|
if verified_account and verified_account.platform == Platform.EMAIL:
|
||||||
|
data["email"] = verified_account.handle
|
||||||
|
email_readonly = True
|
||||||
|
else:
|
||||||
|
email_readonly = False
|
||||||
|
form = RegistrationForm(
|
||||||
|
data,
|
||||||
|
instance=(
|
||||||
|
User.objects.get(pk=request.user.pk)
|
||||||
|
if request.user.is_authenticated
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
# logged in user to change email
|
||||||
|
current_email = (
|
||||||
|
request.user.email_account.handle
|
||||||
|
if request.user.email_account
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
form.cleaned_data["email"]
|
||||||
|
and form.cleaned_data["email"] != current_email
|
||||||
|
):
|
||||||
|
Email.send_login_email(request, form.cleaned_data["email"], "verify")
|
||||||
|
return render(request, "users/verify.html")
|
||||||
|
else:
|
||||||
|
# new user to finalize registration process
|
||||||
|
username = form.cleaned_data["username"]
|
||||||
|
if not username:
|
||||||
|
error = _("Valid username required")
|
||||||
|
elif User.objects.filter(username__iexact=username).exists():
|
||||||
|
error = _("Username in use")
|
||||||
|
else:
|
||||||
|
# all good, create new user
|
||||||
|
new_user = User.register(username=username, account=verified_account)
|
||||||
|
auth_login(request, new_user)
|
||||||
|
|
||||||
|
if not email_readonly and form.cleaned_data["email"]:
|
||||||
|
# if new user wants to link email too
|
||||||
|
request.session["new_user"] = 1
|
||||||
|
Email.send_login_email(
|
||||||
|
request, form.cleaned_data["email"], "verify"
|
||||||
|
)
|
||||||
|
return render(request, "users/verify.html")
|
||||||
|
return render(request, "users/welcome.html")
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"users/register.html",
|
||||||
|
{"form": form, "email_readonly": email_readonly, "error": error},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_preference_cache(request):
|
||||||
|
for key in list(request.session.keys()):
|
||||||
|
if key.startswith("p_"):
|
||||||
|
del request.session[key]
|
||||||
|
|
||||||
|
|
||||||
|
def auth_login(request, user):
|
||||||
|
auth.login(request, user, backend="mastodon.auth.OAuth2Backend")
|
||||||
|
request.session.pop("verified_account", None)
|
||||||
|
request.session.pop("invite", None)
|
||||||
|
clear_preference_cache(request)
|
||||||
|
|
||||||
|
|
||||||
|
def logout_takahe(response: HttpResponse):
|
||||||
|
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def auth_logout(request):
|
||||||
|
auth.logout(request)
|
||||||
|
return logout_takahe(redirect("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_data_task(user_id):
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
user_str = str(user)
|
||||||
|
if user.identity:
|
||||||
|
remove_data_by_user(user.identity)
|
||||||
|
Takahe.delete_identity(user.identity.pk)
|
||||||
|
user.clear()
|
||||||
|
logger.warning(f"User {user_str} data cleared.")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def clear_data(request):
|
||||||
|
if request.META.get("HTTP_AUTHORIZATION"):
|
||||||
|
raise BadRequest("Only for web login")
|
||||||
|
if request.method == "POST":
|
||||||
|
v = request.POST.get("verification", "").strip()
|
||||||
|
if v:
|
||||||
|
for acct in request.user.social_accounts.all():
|
||||||
|
if acct.handle == v:
|
||||||
|
django_rq.get_queue("mastodon").enqueue(
|
||||||
|
clear_data_task, request.user.id
|
||||||
|
)
|
||||||
|
messages.add_message(
|
||||||
|
request, messages.INFO, _("Account is being deleted.")
|
||||||
|
)
|
||||||
|
return auth_logout(request)
|
||||||
|
messages.add_message(request, messages.ERROR, _("Account mismatch."))
|
||||||
|
return redirect(reverse("users:data"))
|
|
@ -16,9 +16,9 @@ from common.utils import (
|
||||||
from mastodon.api import *
|
from mastodon.api import *
|
||||||
from takahe.utils import Takahe
|
from takahe.utils import Takahe
|
||||||
|
|
||||||
|
from ..models import APIdentity
|
||||||
from .account import *
|
from .account import *
|
||||||
from .data import *
|
from .data import *
|
||||||
from .models import APIdentity
|
|
||||||
|
|
||||||
|
|
||||||
def query_identity(request, handle):
|
def query_identity(request, handle):
|
|
@ -12,8 +12,7 @@ from django.urls import reverse
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from common.config import *
|
from common.utils import GenerateDateUUIDMediaFilePath
|
||||||
from common.utils import GenerateDateUUIDMediaFilePath, profile_identity_required
|
|
||||||
from journal.exporters.doufen import export_marks_task
|
from journal.exporters.doufen import export_marks_task
|
||||||
from journal.importers.douban import DoubanImporter
|
from journal.importers.douban import DoubanImporter
|
||||||
from journal.importers.goodreads import GoodreadsImporter
|
from journal.importers.goodreads import GoodreadsImporter
|
||||||
|
@ -23,8 +22,8 @@ from journal.models import ShelfType, reset_journal_visibility_for_user
|
||||||
from mastodon.api import *
|
from mastodon.api import *
|
||||||
from social.models import reset_social_visibility_for_user
|
from social.models import reset_social_visibility_for_user
|
||||||
|
|
||||||
|
from ..tasks import *
|
||||||
from .account import *
|
from .account import *
|
||||||
from .tasks import *
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
|
@ -52,7 +52,7 @@ def account_profile(request):
|
||||||
i = form.save()
|
i = form.save()
|
||||||
Takahe.update_state(i, "edited")
|
Takahe.update_state(i, "edited")
|
||||||
u = request.user
|
u = request.user
|
||||||
if u.mastodon_acct and not u.preference.mastodon_skip_userinfo:
|
if u.mastodon and not u.preference.mastodon_skip_userinfo:
|
||||||
u.preference.mastodon_skip_userinfo = True
|
u.preference.mastodon_skip_userinfo = True
|
||||||
u.preference.save(update_fields=["mastodon_skip_userinfo"])
|
u.preference.save(update_fields=["mastodon_skip_userinfo"])
|
||||||
return HttpResponseRedirect(reverse("users:info"))
|
return HttpResponseRedirect(reverse("users:info"))
|
Loading…
Add table
Reference in a new issue