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}),
|
||||
# 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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 + " "
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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=""
|
||||
)
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
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",
|
||||
"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 = []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
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.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,
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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" %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
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 takahe.utils import Takahe
|
||||
|
||||
from ..models import APIdentity
|
||||
from .account import *
|
||||
from .data import *
|
||||
from .models import APIdentity
|
||||
|
||||
|
||||
def query_identity(request, handle):
|
|
@ -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
|
|
@ -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"))
|
Loading…
Add table
Reference in a new issue