login via threads.net

This commit is contained in:
Your Name 2024-07-03 00:07:07 -04:00 committed by Henri Dickson
parent ac4f12c831
commit 2bd3aaa78d
57 changed files with 1132 additions and 764 deletions

View file

@ -102,6 +102,9 @@ env = environ.FileAwareEnv(
DISCORD_WEBHOOKS=(dict, {"user-report": None}),
# Slack API token, for sending exceptions to Slack, may deprecate in future
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=(bool, False),
NEODB_SENTRY_DSN=(str, ""),
@ -174,8 +177,11 @@ elif _parsed_email_url.scheme:
else:
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_INFO = {

View file

@ -27,9 +27,10 @@ urlpatterns = [
path("login/", login),
path("markdownx/", include("markdownx.urls")),
path("account/", include("users.urls")),
path("account/", include("mastodon.urls")),
path(
"users/connect/",
RedirectView.as_view(url="/account/connect", query_string=True),
RedirectView.as_view(url="/mastodon/login", query_string=True),
),
path(
"auth/edit", # some apps like elk will use this url

View file

@ -1,4 +1,3 @@
import django
from django.conf import settings
from django.core.checks import Error, Warning
from loguru import logger
@ -7,7 +6,6 @@ from catalog.search.models import Indexer
from common.models import JobManager
from takahe.models import Config as TakaheConfig
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 Relay as TakaheRelay
from takahe.models import User as TakaheUser

View file

@ -119,13 +119,6 @@
</ul>
</nav>
</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 %}
<ul class="messages" style="text-align:center">
{% for message in messages %}

View file

@ -54,16 +54,26 @@
</a>
</span>
{% endif %}
{% if identity.user.mastodon_account %}
{% if identity.user.mastodon %}
<span>
<a href="{{ identity.user.mastodon_account.url }}"
<a href="{{ identity.user.mastodon.url }}"
target="_blank"
rel="noopener"
title="@{{ identity.user.mastodon_acct }}">
title="@{{ identity.user.mastodon.handle }}">
<i class="fa-brands fa-mastodon"></i>
</a>
</span>
{% 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 %}
{% include 'users/profile_actions.html' %}
{% endif %}

View file

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

View file

@ -11,6 +11,12 @@ from takahe.utils import Takahe
from .api import api
def render_error(request, title, message=""):
return render(
request, "common/error.html", {"msg": title, "secondary_msg": message}
)
@login_required
def me(request):
if not request.user.registration_complete:

View file

@ -67,6 +67,9 @@ x-shared:
TAKAHE_VAPID_PRIVATE_KEY:
TAKAHE_DEBUG: ${NEODB_DEBUG:-False}
TAKAHE_VENV: /takahe-venv
THREADS_APP_ID:
THREADS_APP_SECRET:
BLUESKY_LOGIN_ENABLED:
SPOTIFY_API_KEY:
TMDB_API_V3_KEY:
GOOGLE_API_KEY:

View file

@ -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
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`
add the following line to `.env` to make it an admin account:

View file

@ -8,14 +8,14 @@ from typing import TYPE_CHECKING, Any, Self
# from deepmerge import always_merger
from django.conf import settings
from django.core.signing import b62_decode, b62_encode
from django.db import connection, models
from django.db.models import Avg, CharField, Count, Q
from django.db import models
from django.db.models import CharField, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from loguru import logger
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 takahe.utils import Takahe
from users.models import APIdentity, User
@ -316,7 +316,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
def sync_to_mastodon(self, delete_existing=False):
user = self.owner.user
if not user.mastodon_site:
if not user.mastodon:
return
if user.preference.mastodon_repost_mode == 1:
if delete_existing:

View file

@ -6,7 +6,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from loguru import logger
from catalog.models import Item, ItemCategory
from catalog.models import Item
from mastodon.models import get_spoiler_text
from takahe.utils import Takahe
from users.models import APIdentity, User
@ -309,8 +309,11 @@ class Mark:
post_as_new = shelf_type != last_shelf_type or visibility != last_visibility
classic_crosspost = user.preference.mastodon_repost_mode == 1
append = (
f"@{user.mastodon_acct}\n"
if visibility > 0 and share_to_mastodon and not classic_crosspost
f"@{user.mastodon.handle}\n"
if visibility > 0
and share_to_mastodon
and not classic_crosspost
and user.mastodon
else ""
)
post = Takahe.post_mark(self, post_as_new, append)

View file

@ -58,7 +58,7 @@
</div>
<div class="info">
<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>
</p>
<p>

View file

@ -51,7 +51,7 @@
</div>
<div>
<fieldset>
{% if request.user.mastodon_acct %}
{% if request.user.mastodon %}
<label for="id_share_to_mastodon">
<input role="switch"
type="checkbox"

View file

@ -109,7 +109,7 @@
</div>
<div>
<fieldset>
{% if request.user.mastodon_acct %}
{% if request.user.mastodon %}
<label for="id_share_to_mastodon">
<input role="switch"
type="checkbox"

View file

@ -238,10 +238,8 @@
{% include "_sidebar.html" with show_progress=1 show_profile=1 %}
</main>
{% include "_footer.html" %}
{% if identity.user and identity.user.mastodon_account %}
<a href="{{ identity.user.mastodon_account.url }}"
rel="me"
style="display:none">Mastodon verification</a>
{% if identity.user and identity.user.mastodon %}
<a href="{{ identity.user.mastodon.url }}" rel="me" style="display:none">Mastodon verification</a>
{% endif %}
</body>
</html>

View file

@ -58,7 +58,7 @@
var cats = JSON.parse(document.getElementById('cat-data').textContent);
var data = JSON.parse(document.getElementById('data').textContent);
var opts = {
title: "@{{ identity.user.mastodon_acct | default:identity.full_handle }} - {{ year }}",
title: "@{{ identity.user.mastodon.handle | default:identity.full_handle }} - {{ year }}",
element: '#viz0',
font: 1,
data: data,

View file

@ -172,8 +172,8 @@ def share_collection(
else (
_("shared {username}'s collection").format(
username=(
" @" + collection.owner.user.mastodon_acct + " "
if collection.owner.user.mastodon_acct
" @" + collection.owner.user.mastodon.handle + " "
if collection.owner.user.mastodon
else " " + collection.owner.username + " "
)
)

View file

@ -1,7 +1,7 @@
import datetime
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.db.models import F, Min, OuterRef, Subquery
from django.shortcuts import get_object_or_404, redirect, render
@ -14,7 +14,6 @@ from common.utils import (
AuthedHttpRequest,
PageLinksGenerator,
get_uuid_or_404,
profile_identity_required,
target_identity_required,
)
@ -29,7 +28,9 @@ def render_relogin(request):
request,
"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."),
"secondary_msg": _(
"Redirecting to your Fediverse instance now to re-authenticate."

View file

@ -5,12 +5,15 @@ from django.shortcuts import render
from django.views.decorators.http import require_http_methods
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 ..forms import *
from ..models import *
from .common import profile_identity_required, target_identity_required
@require_http_methods(["GET"])

View file

@ -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 mastodon.models.common import SocialAccount
from .models import Mastodon
class OAuth2Backend(ModelBackend):
"""Used to glue OAuth2 and Django User model"""

View file

@ -8,7 +8,7 @@ class Bluesky:
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(
json_field_name="access_data", default=""
)

View file

@ -2,7 +2,6 @@ from django.db import models
from django.db.models.functions import Lower
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from loguru import logger
from typedmodels.models import TypedModel
from catalog.common import jsondata
@ -67,12 +66,15 @@ class SocialAccount(TypedModel):
]
def __str__(self) -> str:
return f"{self.platform}:{self.handle}"
return f"({self.pk}){self.platform}#{self.handle}:{self.uid}@{self.domain}"
@property
def platform(self) -> Platform:
return Platform(self.type.replace("mastodon.", "", 1).replace("account", "", 1))
def sync_later(self):
pass
def to_dict(self):
# skip cached_property, datetime and other non-serializable fields
d = {

View file

@ -1,19 +1,14 @@
import random
from datetime import timedelta
from os.path import exists
from urllib.parse import quote
import django_rq
from django.conf import settings
from django.core.cache import cache
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.utils.translation import gettext as _
from loguru import logger
from catalog.common import jsondata
from .common import SocialAccount
_code_ttl = 60 * 15
@ -24,6 +19,14 @@ class EmailAccount(SocialAccount):
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
def _send(email, subject, body):
try:
@ -88,8 +91,4 @@ class Email:
existing_account = EmailAccount.objects.filter(handle__iexact=email).first()
if existing_account:
return existing_account
sp = email.split("@", 1)
if len(sp) != 2:
return None
account = EmailAccount(handle=email, uid=sp[0], domain=sp[1])
return account
return Email.new_account(email)

View file

@ -2,17 +2,14 @@ import functools
import random
import re
import string
import time
import typing
from enum import StrEnum
from urllib.parse import quote
import django_rq
import httpx
import requests
from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.db import models
from django.db.models import Count
from django.http import HttpRequest
@ -24,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
from loguru import logger
from catalog.common import jsondata
from takahe.utils import Takahe
from .common import SocialAccount
@ -395,7 +393,7 @@ def verify_client(mast_app):
def obtain_token(site, code, request):
"""Returns token if success else None."""
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 = {
"client_id": mast_app.client_id,
"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):
url = request.build_absolute_uri(reverse("users:login_oauth"))
url = request.build_absolute_uri(reverse("mastodon:oauth"))
version = app.server_version or ""
scope = (
settings.MASTODON_LEGACY_CLIENT_SCOPE
@ -838,3 +836,6 @@ class MastodonAccount(SocialAccount):
spoiler_text,
attachments,
)
def sync_later(self):
Takahe.fetch_remote_identity(self.handle)

View file

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

View file

@ -0,0 +1,3 @@
from .email import *
from .mastodon import *
from .threads import *

86
mastodon/views/common.py Normal file
View 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
View 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)

View 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
View 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"))

View file

@ -55,7 +55,8 @@ dependencies = [
"urlman",
"validators",
"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]
@ -93,7 +94,7 @@ django_settings_module = "boofilsic.settings"
[tool.ruff]
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]
py-modules = []

View file

@ -20,6 +20,7 @@ asgiref==3.8.1
# via django
# via django-cors-headers
# via django-stubs
atproto==0.0.48
attrs==23.2.0
# via aiohttp
babel==2.15.0
@ -44,6 +45,7 @@ cfgv==3.4.0
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via atproto
# via black
# via djlint
# via mkdocs
@ -52,6 +54,7 @@ colorama==0.4.6
# via djlint
# via mkdocs-material
cryptography==42.0.8
# via atproto
# via jwcrypto
cssbeautifier==1.15.1
# via djlint
@ -103,11 +106,12 @@ django-slack==5.19.0
django-stubs==5.0.2
django-stubs-ext==5.0.2
# 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-user-messages==1.0.0
djlint==1.34.1
dnspython==2.6.1
# via atproto
easy-thumbnails==2.8.5
editorconfig==0.12.4
# via cssbeautifier
@ -131,7 +135,8 @@ html-void-elements==0.1.0
# via djlint
httpcore==1.0.5
# via httpx
httpx==0.27.0
httpx==0.26.0
# via atproto
identify==2.5.36
# via pre-commit
idna==3.7
@ -152,6 +157,8 @@ json5==0.9.25
jwcrypto==1.5.6
# via django-oauth-toolkit
langdetect==1.0.9
libipld==1.2.3
# via atproto
libsass==0.23.0
listparser==0.20
loguru==0.7.2
@ -214,6 +221,7 @@ psycopg2-binary==2.9.9
pycparser==2.22
# via cffi
pydantic==2.7.3
# via atproto
# via django-ninja
pydantic-core==2.18.4
# via pydantic
@ -287,6 +295,7 @@ types-pyyaml==6.0.12.20240311
# via django-stubs
typesense==0.21.0
typing-extensions==4.12.1
# via atproto
# via django-stubs
# via django-stubs-ext
# via jwcrypto
@ -307,5 +316,7 @@ watchdog==4.0.1
webencodings==0.5.1
# via bleach
# via tinycss2
websockets==12.0
# via atproto
yarl==1.9.4
# via aiohttp

View file

@ -19,6 +19,7 @@ anyio==4.4.0
asgiref==3.8.1
# via django
# via django-cors-headers
atproto==0.0.48
attrs==23.2.0
# via aiohttp
beautifulsoup4==4.12.3
@ -38,8 +39,10 @@ cffi==1.16.0
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via atproto
# via rq
cryptography==42.0.8
# via atproto
# via jwcrypto
dateparser==1.2.0
deepmerge==1.1.1
@ -82,10 +85,11 @@ django-rq==2.10.2
django-sass-processor==1.4.1
django-simple-history==3.7.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-user-messages==1.0.0
dnspython==2.6.1
# via atproto
easy-thumbnails==2.8.5
et-xmlfile==1.1.0
# via openpyxl
@ -98,7 +102,8 @@ h11==0.14.0
# via httpcore
httpcore==1.0.5
# via httpx
httpx==0.27.0
httpx==0.26.0
# via atproto
idna==3.7
# via anyio
# via httpx
@ -108,6 +113,8 @@ igdb-api-v4==0.3.2
jwcrypto==1.5.6
# via django-oauth-toolkit
langdetect==1.0.9
libipld==1.2.3
# via atproto
libsass==0.23.0
listparser==0.20
loguru==0.7.2
@ -135,6 +142,7 @@ psycopg2-binary==2.9.9
pycparser==2.22
# via cffi
pydantic==2.7.3
# via atproto
# via django-ninja
pydantic-core==2.18.4
# via pydantic
@ -184,6 +192,7 @@ tinycss2==1.1.1
tqdm==4.66.4
typesense==0.21.0
typing-extensions==4.12.1
# via atproto
# via jwcrypto
# via pydantic
# via pydantic-core
@ -198,5 +207,7 @@ validators==0.28.3
webencodings==0.5.1
# via bleach
# via tinycss2
websockets==12.0
# via atproto
yarl==1.9.4
# via aiohttp

View file

@ -15,12 +15,8 @@ class SocialTest(TestCase):
self.book1 = Edition.objects.create(title="Hyperion")
self.book2 = Edition.objects.create(title="Andymion")
self.movie = Edition.objects.create(title="Fight Club")
self.alice = User.register(
username="Alice", mastodon_site="MySpace", mastodon_username="Alice"
)
self.bob = User.register(
username="Bob", mastodon_site="KKCity", mastodon_username="Bob"
)
self.alice = User.register(username="Alice")
self.bob = User.register(username="Bob")
def test_timeline(self):
alice_feed = self.alice.identity.activity_manager

View file

@ -7,7 +7,6 @@ from loguru import logger
from catalog.common import *
from journal.models import (
Comment,
Content,
Note,
Piece,
PieceInteraction,
@ -18,7 +17,7 @@ from journal.models import (
from users.middlewares import activate_language_for_user
from users.models.apidentity import APIdentity
from .models import Follow, Identity, Post, TimelineEvent
from .models import Identity, Post, TimelineEvent
from .utils import Takahe
_supported_ap_catalog_item_types = [
@ -155,7 +154,8 @@ def post_interacted(interaction_pk, interaction, post_pk, identity_pk):
if (
interaction == "boost"
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
TimelineEvent.objects.filter(

View file

@ -628,8 +628,6 @@ class Takahe:
@staticmethod
def post_mark(mark, share_as_new_post: bool, append_content="") -> Post | None:
from catalog.common import ItemCategory
user = mark.owner.user
stars = _rating_to_emoji(mark.rating_grade, 1)
item_link = f"{settings.SITE_INFO['site_url']}/~neodb~{mark.item.url}"
@ -804,7 +802,7 @@ class Takahe:
return FediverseHtmlParser(linebreaks_filter(txt)).html
@staticmethod
def update_state(obj: Post | Relay, state: str):
def update_state(obj: Post | Relay | Identity, state: str):
obj.state = state
obj.state_changed = timezone.now()
obj.state_next_attempt = None

View file

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

View file

@ -1,12 +1,11 @@
from ninja import Schema
from ninja.security import django_auth
from common.api import *
class UserSchema(Schema):
url: str
external_acct: str
external_acct: str | None
display_name: str
avatar: str
username: str
@ -22,7 +21,9 @@ def me(request):
return 200, {
"username": request.user.username,
"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,
"avatar": request.user.avatar,
}

View file

@ -14,6 +14,9 @@ class Command(BaseCommand):
def handle(self, *args, **options):
m = 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)):
if user.mastodon_username:
MastodonAccount.objects.update_or_create(

View file

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

View file

@ -10,20 +10,23 @@ from django.contrib.auth.validators import UnicodeUsernameValidator
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models, transaction
from django.db.models import F, Manager, Q, Value
from django.db.models.functions import Concat, Lower
from django.db.models.functions import Lower
from django.urls import reverse
from django.utils import timezone, translation
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
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
if TYPE_CHECKING:
from mastodon.models import Mastodon
from .apidentity import APIdentity
from .preference import Preference
@ -59,18 +62,31 @@ class UsernameValidator(UnicodeUsernameValidator):
class UserManager(BaseUserManager):
def create_user(self, username, email, password=None):
from mastodon.models import Email
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
def create_superuser(self, username, email, password=None):
from mastodon.models import Email
from takahe.models import User as TakaheUser
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.admin = True
tu.set_password(password)
tu.save()
return user
@ -159,37 +175,21 @@ class User(AbstractUser):
Lower("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
def mastodon(self) -> "MastodonAccount | None":
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
def email_account(self) -> "EmailAccount | None":
return EmailAccount.objects.filter(user=self).first()
@ -239,34 +239,21 @@ class User(AbstractUser):
return p.edited_time if p else None
def clear(self):
if self.mastodon_site == "removed" and not self.is_active:
if not self.is_active:
return
with transaction.atomic():
self.first_name = self.mastodon_acct or ""
self.last_name = self.email or ""
accts = [str(a) for a in self.social_accounts.all()]
self.first_name = (";").join(accts)
self.last_name = self.username
self.is_active = False
self.email = None
# self.username = "~removed~" + str(self.pk)
# 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.identity.deleted = timezone.now()
self.identity.save()
SocialAccount.objects.filter(user=self).delete()
self.social_accounts.all().delete()
def sync_relationship(self):
from .apidentity import APIdentity
def get_identity_ids(accts: list):
return set(
MastodonAccount.objects.filter(handle__in=accts).values_list(
@ -301,29 +288,59 @@ class User(AbstractUser):
Takahe.mute(me, target_identity)
def sync_identity(self):
"""sync display name, bio, and avatar from available sources"""
identity = self.identity.takahe_identity
if identity.deleted:
logger.error(f"Identity {identity} is deleted, skip sync")
return
mastodon = self.mastodon
if not mastodon:
return
identity.name = mastodon.display_name or identity.name or identity.username
identity.summary = mastodon.note or identity.summary
identity.manually_approves_followers = mastodon.locked
if not bool(identity.icon) or identity.icon_uri != mastodon.avatar:
identity.icon_uri = mastodon.avatar
if identity.icon_uri:
threads = self.threads
# bluesky = self.bluesky
changed = False
name = (
(mastodon.display_name if mastodon else "")
or (threads.username if threads else "")
# or (bluesky.display_name if bluesky else "")
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:
r = httpx.get(identity.icon_uri)
r = httpx.get(url)
f = ContentFile(r.content, name=identity.icon_uri.split("/")[-1])
identity.icon.save(f.name, f, save=False)
changed = True
except Exception as e:
logger.error(
f"fetch icon failed: {identity} {identity.icon_uri}",
extra={"exception": e},
)
if changed:
identity.save()
Takahe.update_state(identity, "outdated")
def refresh_mastodon_data(self, skip_detail=False, sleep_hours=0):
"""Try refresh account data from mastodon server, return True if refreshed successfully"""
@ -387,18 +404,12 @@ class User(AbstractUser):
account = param.pop("account", None)
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)
if not new_user.username:
raise ValueError("username is not set")
if "language" not in param:
new_user.language = translation.get_language()
new_user.set_unusable_password()
new_user.save()
Preference.objects.create(user=new_user)
if account:
@ -406,10 +417,14 @@ class User(AbstractUser):
account.save()
Takahe.init_identity_for_local_user(new_user)
new_user.identity.shelf_manager
if new_user.mastodon:
Takahe.fetch_remote_identity(new_user.mastodon.handle)
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

View file

@ -17,7 +17,7 @@
{% if allow_any_site %}
<article>
<details>
<summary>{% trans 'Username, Email and Identities' %}</summary>
<summary>{% trans 'Username and Email' %}</summary>
<form action="{% url 'users:register' %}?next={{ request.path }}"
method="post">
<small>{{ error }}</small>
@ -46,16 +46,26 @@
{% csrf_token %}
<input type="submit" value="{% trans 'Save' %}" disabled id="save">
</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 %}
<fieldset>
{% if request.user.mastodon_acct %}
{% if request.user.mastodon %}
<label>
{% trans "Associated identities" %}
<i class="fa-brands fa-mastodon"></i> {% trans "Verified Identity" %}
<input type="input"
{% if request.user.mastodon_acct %}aria-invalid="false"{% endif %}
value="{{ request.user.mastodon_acct | default:'-' }}"
{% if request.user.mastodon %}aria-invalid="false"{% endif %}
value="{{ request.user.mastodon.handle | default:'-' }}"
readonly>
<small>
{% if request.user.mastodon.last_refresh %}
{% trans "Last updated" %} {{ request.user.mastodon.last_refresh }}
{% endif %}
</small>
</label>
{% else %}
<p>
@ -63,7 +73,7 @@
</p>
{% endif %}
<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." %}
{% else %}
{% trans "If you have registered with a Federated instance, please enter the instance domain name." %}
@ -79,7 +89,7 @@
disabled
id="bind" />
<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 %}
{% else %}
{% trans "Once associated with Fediverse identity, you can discover more users and use the full features of this site." %}
@ -89,13 +99,39 @@
</form>
</details>
</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 %}
<article>
<details>
<summary>{% trans "Display name, avatar and other information" %}</summary>
<form action="{% url 'users:profile' %}?next={{ request.path }}"
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">
{% include "_field.html" with field=profile_form.name %}
{% include "_field.html" with field=profile_form.summary %}
@ -176,13 +212,14 @@
value="{% trans 'Sync now' %}"
{% if not request.user.mastodon_username %}disabled{% endif %} />
<small>
{% if request.user.mastodon_last_refresh %}
{% trans "Last updated" %} {{ request.user.mastodon_last_refresh }}
{% if request.user.mastodon.last_refresh %}
{% trans "Last updated" %} {{ request.user.mastodon.last_refresh }}
{% endif %}
</small>
</form>
</details>
</article>
{% if allow_any_site %}
<article>
<details>
<summary>{% trans 'Delete Account' %}</summary>
@ -215,6 +252,7 @@
</form>
</details>
</article>
{% endif %}
</div>
{% include "_sidebar.html" with show_profile=1 identity=request.user.identity %}
</main>

View file

@ -12,10 +12,8 @@
<meta property="og:image" content="{{ identity.avatar }}">
</head>
<body>
{% if identity.local and identity.user.mastodon_account.url %}
<a href="{{ identity.user.mastodon_account.url }}"
rel="me"
style="display:none">Mastodon verification</a>
{% if identity.local and identity.user.mastodon %}
<a href="{{ identity.user.mastodon.url }}" rel="me" style="display:none">Mastodon verification</a>
{% endif %}
</body>
</html>

View file

@ -70,22 +70,24 @@
</svg>
<!--<i class="fa-brands fa-mastodon"></i>-->
</button>
<!--
{% 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
<form/>
then show #login-threads" id="platform-threads" title="{% trans "Threads" %}">
<i class="fa-brands fa-threads"></i>
</button>
{% endif %}
{% 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
<form/>
then show #login-bluesky" id="platform-bluesky" title="{% trans "Bluesky" %}">
<i class="fa-brands fa-bluesky" style="font-size:85%"></i>
</button>
{% endif %}
-->
</div>
<form id="login-email"
style="display:none"
action="{% url 'users:connect' %}"
action="{% url 'mastodon:email_login' %}"
method="post"
onsubmit="return login(this);">
{% csrf_token %}
@ -100,7 +102,7 @@
</form>
<form id="login-mastodon"
style="display:none"
action="{% url 'users:connect' %}"
action="{% url 'mastodon:login' %}"
method="post"
onsubmit="return login(this);">
{% csrf_token %}
@ -127,7 +129,7 @@
</form>
<form id="login-threads"
style="display:none"
action="{% url 'users:connect' %}"
action="{% url 'mastodon:threads_login' %}"
method="post"
onsubmit="return login(this);">
{% csrf_token %}
@ -137,7 +139,7 @@
</form>
<form id="login-bluesky"
style="display:none"
action="{% url 'users:connect' %}"
action="{% url 'mastodon:login' %}"
method="post"
onsubmit="return login(this);">
{% csrf_token %}

View file

@ -90,7 +90,7 @@
<label for="post_public_mode_4">{% trans "local, this site only" %}</label>
{% endif %}
</fieldset>
{% if request.user.mastodon_acct %}
{% if request.user.mastodon %}
<fieldset>
<label>
{% trans "Turn on crosspost to timeline by default" %}

View file

@ -54,17 +54,27 @@
</a>
</span>
{% endif %}
{% if identity.user.mastodon_account %}
{% if not identity.locked or request.user.is_superuser or relationship.requested or relationship.status %}
{% if identity.user.mastodon %}
<span>
<a href="{{ identity.user.mastodon_account.url }}"
<a href="{{ identity.user.mastodon.url }}"
target="_blank"
rel="noopener"
title="@{{ identity.user.mastodon_acct }}">
title="@{{ identity.user.mastodon.handle }}">
<i class="fa-brands fa-mastodon"></i>
</a>
</span>
{% 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 %}
{% if relationship.requested %}

View file

@ -12,7 +12,10 @@
<div class="container">
<article>
<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>
{% if form %}
<form action="{% url 'users:register' %}" method="post">

View file

@ -27,7 +27,7 @@
text-align: center;
}
</style>
<form action="{% url 'users:verify_code' %}" method="post">
<form action="{% url 'mastodon:email_verify' %}" method="post">
<div class="otp">
<input name="code"
maxlength="5"

View file

@ -12,7 +12,10 @@
<div class="container">
<article>
<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>
<h4>{% trans "Welcome" %}</h4>
<p>

View file

@ -11,15 +11,11 @@ class UserTest(TestCase):
databases = "__all__"
def setUp(self):
self.alice = User.register(
mastodon_site="MySpace", mastodon_username="Alice", username="alice"
).identity
self.alice = User.register(username="alice").identity
MastodonAccount.objects.create(
handle="Alice@MySpace", user=self.alice.user, domain="MySpace", uid="42"
)
self.bob = User.register(
mastodon_site="KKCity", mastodon_username="Bob", username="bob"
).identity
self.bob = User.register(username="bob").identity
self.domain = settings.SITE_INFO.get("site_domain")
def test_handle(self):

View file

@ -1,17 +1,11 @@
from django.urls import path
from .account import *
from .profile import *
from .views import *
app_name = "users"
urlpatterns = [
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("connect", connect, name="connect"),
path("reconnect", reconnect, name="reconnect"),
path("fetch_refresh", fetch_refresh, name="fetch_refresh"),
path("data", data, name="data"),
path("info", account_info, name="info"),

4
users/views/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from .account import *
from .actions import *
from .data import *
from .profile import *

242
users/views/account.py Normal file
View 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"))

View file

@ -16,9 +16,9 @@ from common.utils import (
from mastodon.api import *
from takahe.utils import Takahe
from ..models import APIdentity
from .account import *
from .data import *
from .models import APIdentity
def query_identity(request, handle):

View file

@ -12,8 +12,7 @@ from django.urls import reverse
from django.utils import translation
from django.utils.translation import gettext as _
from common.config import *
from common.utils import GenerateDateUUIDMediaFilePath, profile_identity_required
from common.utils import GenerateDateUUIDMediaFilePath
from journal.exporters.doufen import export_marks_task
from journal.importers.douban import DoubanImporter
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 social.models import reset_social_visibility_for_user
from ..tasks import *
from .account import *
from .tasks import *
@login_required

View file

@ -52,7 +52,7 @@ def account_profile(request):
i = form.save()
Takahe.update_state(i, "edited")
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.save(update_fields=["mastodon_skip_userinfo"])
return HttpResponseRedirect(reverse("users:info"))