refactor mastodon login

This commit is contained in:
Your Name 2024-07-01 17:29:38 -04:00 committed by Henri Dickson
parent 6eaf5397bc
commit 8aef26a588
45 changed files with 2363 additions and 1989 deletions

View file

@ -1,3 +1,4 @@
import logging
import os
import sys
@ -164,10 +165,17 @@ if _parsed_email_url.scheme == "anymail":
EMAIL_BACKEND = _parsed_email_url.hostname
ANYMAIL = dict(parse.parse_qsl(_parsed_email_url.query))
ENABLE_LOGIN_EMAIL = True
elif _parsed_email_url.scheme:
_parsed_email_config = env.email("NEODB_EMAIL_URL")
EMAIL_TIMEOUT = 5
vars().update(_parsed_email_config)
ENABLE_LOGIN_EMAIL = True
else:
ENABLE_LOGIN_EMAIL = False
ENABLE_LOGIN_THREADS = False
ENABLE_LOGIN_BLUESKY = False
SITE_DOMAIN = env("NEODB_SITE_DOMAIN").lower()
SITE_INFO = {
@ -199,12 +207,6 @@ MIN_MARKS_FOR_DISCOVER = env("NEODB_MIN_MARKS_FOR_DISCOVER")
MASTODON_ALLOWED_SITES = env("NEODB_LOGIN_MASTODON_WHITELIST")
# Allow user to create account with email (and link to Mastodon account later)
ALLOW_EMAIL_ONLY_ACCOUNT = env.bool(
"NEODB_LOGIN_ENABLE_EMAIL_ONLY",
default=(_parsed_email_url.scheme and len(MASTODON_ALLOWED_SITES) == 0), # type: ignore
)
# Allow user to login via any Mastodon/Pleroma sites
MASTODON_ALLOW_ANY_SITE = len(MASTODON_ALLOWED_SITES) == 0
@ -376,6 +378,9 @@ LOGGING = {
},
}
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
if SLACK_TOKEN:
INSTALLED_APPS += [
"django_slack",

View file

@ -1,4 +1,4 @@
# pyright: reportIncompatibleMethodOverride=false, reportFunctionMemberAccess=false
# pyright: reportIncompatibleMethodOverride=false
import copy
from base64 import b64decode, b64encode
from datetime import date, datetime
@ -7,10 +7,11 @@ from hashlib import sha256
from importlib import import_module
import django
import loguru
from cryptography.fernet import Fernet, MultiFernet
from django.conf import settings
from django.core.exceptions import FieldError
from django.db.models import Value, fields
from django.db.models import DEFERRED, fields # type:ignore
from django.utils import dateparse, timezone
from django.utils.encoding import force_bytes
from django.utils.translation import gettext_lazy as _
@ -20,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
# from django.contrib.postgres.fields import ArrayField as DJANGO_ArrayField
from django_jsonform.models.fields import ArrayField as DJANGO_ArrayField
from django_jsonform.models.fields import JSONField as DJANGO_JSONField
from loguru import logger
def _get_crypter():
@ -74,18 +76,20 @@ class JSONFieldDescriptor(object):
return self
json_value = getattr(instance, self.field.json_field_name)
if isinstance(json_value, dict):
if self.field.attname in json_value or not self.field.has_default():
if self.field.attname in json_value:
value = json_value.get(self.field.attname, None)
if hasattr(self.field, "from_json"):
value = self.field.from_json(value)
return value
elif self.field.has_default():
value = self.field.get_default()
# if hasattr(self.field, "to_json"):
# json_value[self.field.attname] = self.field.to_json(value)
# else:
# json_value[self.field.attname] = value
# return value
else:
default = self.field.get_default()
if hasattr(self.field, "to_json"):
json_value[self.field.attname] = self.field.to_json(default)
else:
json_value[self.field.attname] = default
return default
value = None
return value
return None
def __set__(self, instance, value):
@ -123,7 +127,7 @@ class JSONFieldMixin(object):
self.set_attributes_from_name(name)
self.model = cls
self.concrete = False
self.column = self.json_field_name # type: ignore
self.column = None # type: ignore
cls._meta.add_field(self, private=True)
if not getattr(cls, self.attname, None):
@ -137,11 +141,13 @@ class JSONFieldMixin(object):
partialmethod(cls._get_FIELD_display, field=self),
)
self.column = self.json_field_name # type: ignore
def get_lookup(self, lookup_name):
# Always return None, to make get_transform been called
return None
def get_transform(self, name):
def get_transform(self, lookup_name):
class TransformFactoryWrapper:
def __init__(self, json_field, transform, original_lookup):
self.json_field = json_field
@ -164,17 +170,22 @@ class JSONFieldMixin(object):
if transform is None:
raise FieldError(
"JSONField '%s' has no support for key '%s' %s lookup"
% (self.json_field_name, self.name, name) # type: ignore
% (self.json_field_name, self.name, lookup_name) # type: ignore
)
return TransformFactoryWrapper(json_field, transform, name)
return TransformFactoryWrapper(json_field, transform, lookup_name)
def get_default(self):
# deferred during obj initialization so it don't overwrite json with default value
return DEFERRED
class BooleanField(JSONFieldMixin, fields.BooleanField):
def __init__(self, *args, **kwargs):
super(BooleanField, self).__init__(*args, **kwargs)
if django.VERSION < (2,):
self.blank = False
pass
# def __init__(self, *args, **kwargs):
# super(BooleanField, self).__init__(*args, **kwargs)
# if django.VERSION < (2,):
# self.blank = False
class CharField(JSONFieldMixin, fields.CharField):
@ -209,7 +220,7 @@ class DateTimeField(JSONFieldMixin, fields.DateTimeField):
)
value = v
if isinstance(value, date):
value = datetime.combine(value, datetime.time.min())
value = datetime.combine(value, datetime.min.time())
if not timezone.is_aware(value):
value = timezone.make_aware(value)
return value.isoformat()

View file

@ -21,7 +21,7 @@
<div class="tag-list">
{% for tag in mark.tags %}
<span>
<a href="{% url 'journal:user_tag_member_list' request.user.handler tag %}">{{ tag }}</a>
<a href="{% url 'journal:user_tag_member_list' request.user.username tag %}">{{ tag }}</a>
</span>
{% endfor %}
</div>

View file

@ -42,7 +42,7 @@
<td colspan="3">
<b title="#{{ log.id }}">
{% if request.user.is_staff or log.actor.preference.show_last_edit %}
{{ log.actor.handler }}
{{ log.actor.username }}
{% else %}
<i class="fa-solid fa-user-secret"></i>
{% endif %}

View file

@ -28,7 +28,6 @@ x-shared:
NEODB_LANGUAGE:
NEODB_ADMIN_USERNAMES:
NEODB_INVITE_ONLY:
NEODB_LOGIN_ENABLE_EMAIL_ONLY:
NEODB_LOGIN_MASTODON_WHITELIST:
NEODB_MASTODON_CLIENT_SCOPE:
NEODB_DISABLE_DEFAULT_RELAY:

View file

@ -17,7 +17,6 @@ from polymorphic.models import PolymorphicModel
from catalog.common.models import AvailableItemCategory, Item, ItemCategory
from catalog.models import item_categories, item_content_types
from mastodon.api import boost_toot_later, delete_toot, delete_toot_later, post_toot2
from takahe.utils import Takahe
from users.models import APIdentity, User
@ -157,8 +156,8 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
if self.local:
Takahe.delete_posts(self.all_post_ids)
toot_url = self.get_mastodon_crosspost_url()
if toot_url:
delete_toot_later(self.owner.user, toot_url)
if toot_url and self.owner.user.mastodon:
self.owner.user.mastodon.delete_later(toot_url)
return super().delete(*args, **kwargs)
@property
@ -324,26 +323,28 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
self.delete_mastodon_repost()
return self.crosspost_to_mastodon()
elif self.latest_post:
return boost_toot_later(user, self.latest_post.url)
if user.mastodon:
user.mastodon.boost_later(self.latest_post.url)
else:
logger.warning("No post found for piece")
return False, 404
def delete_mastodon_repost(self):
toot_url = self.get_mastodon_crosspost_url()
if toot_url:
self.set_mastodon_crosspost_url(None)
delete_toot(self.owner.user, toot_url)
if self.owner.user.mastodon:
self.owner.user.mastodon.delete_later(toot_url)
def crosspost_to_mastodon(self):
user = self.owner.user
if not user or not user.mastodon:
return False, -1
d = {
"user": user,
"visibility": self.visibility,
"update_toot_url": self.get_mastodon_crosspost_url(),
}
d.update(self.to_mastodon_params())
response = post_toot2(**d)
response = user.mastodon.post(**d)
if response is not None and response.status_code in [200, 201]:
j = response.json()
if "url" in j:

View file

@ -1,29 +1,15 @@
import re
import uuid
from datetime import datetime
from functools import cached_property
from typing import Any
import django.dispatch
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import connection, models
from django.db.models import Avg, Count, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from loguru import logger
from markdownx.models import MarkdownxField
from polymorphic.models import PolymorphicModel
from catalog.collection.models import Collection as CatalogCollection
from catalog.common import jsondata
from catalog.common.models import Item, ItemCategory
from catalog.common.utils import DEFAULT_ITEM_COVER, piece_cover_path
from mastodon.api import boost_toot_later, share_mark
from catalog.models import Item, ItemCategory
from mastodon.models import get_spoiler_text
from takahe.utils import Takahe
from users.models import APIdentity
from users.models import APIdentity, User
from .comment import Comment
from .note import Note
@ -32,6 +18,33 @@ from .review import Review
from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember, ShelfType
def share_mark(mark, post_as_new=False):
user = mark.owner.user
if not user or not user.mastodon:
return
stars = user.mastodon.rating_to_emoji(mark.rating_grade)
spoiler_text, txt = get_spoiler_text(mark.comment_text or "", mark.item)
content = f"{mark.get_action_for_feed()} {stars}\n{mark.item.absolute_url}\n{txt}{mark.tag_text}"
update_url = (
None if post_as_new else (mark.shelfmember.metadata or {}).get("shared_link")
)
response = user.mastodon.post(
content,
mark.visibility,
update_url,
spoiler_text,
)
if response is not None and response.status_code in [200, 201]:
j = response.json()
if "url" in j:
mark.shelfmember.metadata = {"shared_link": j["url"]}
mark.shelfmember.save(update_fields=["metadata"])
return True, 200
else:
logger.warning(response)
return False, response.status_code if response is not None else -1
class Mark:
"""
Holding Mark for an item on an shelf,
@ -292,7 +305,7 @@ class Mark:
)
self.rating_grade = rating_grade
# publish a new or updated ActivityPub post
user = self.owner.user
user: User = self.owner.user
post_as_new = shelf_type != last_shelf_type or visibility != last_visibility
classic_crosspost = user.preference.mastodon_repost_mode == 1
append = (
@ -308,8 +321,8 @@ class Mark:
if post and share_to_mastodon:
if classic_crosspost:
share_mark(self, post_as_new)
else:
boost_toot_later(user, post.url)
elif user.mastodon:
user.mastodon.boost_later(post.url)
return True
def delete(self, keep_tags=False):

View file

@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _
from loguru import logger
from catalog.models import Item
from mastodon.api import delete_toot_later
from takahe.utils import Takahe
from .common import Content
@ -160,10 +159,7 @@ class Note(Content):
if p and p.local:
# if local piece is created from a post, update post type_data and fanout
p.sync_to_timeline()
if (
owner.user.preference.mastodon_default_repost
and owner.user.mastodon_username
):
if owner.user.preference.mastodon_default_repost and owner.user.mastodon:
p.sync_to_mastodon()
return p

View file

@ -10,7 +10,6 @@ from markdownify import markdownify as md
from markdownx.models import MarkdownxField
from catalog.models import Item
from mastodon.api import boost_toot_later
from takahe.utils import Takahe
from users.models import APIdentity

View file

@ -1,16 +1,14 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import BadRequest, ObjectDoesNotExist, PermissionDenied
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.core.exceptions import BadRequest, PermissionDenied
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, 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 catalog.models import Item
from common.utils import AuthedHttpRequest, get_uuid_or_404
from mastodon.api import boost_toot_later, share_collection
from users.models import User
from ..forms import *
from ..models import *
@ -126,33 +124,69 @@ def collection_share(request: AuthedHttpRequest, collection_uuid):
collection = get_object_or_404(
Collection, uid=get_uuid_or_404(collection_uuid) if collection_uuid else None
)
if collection and not collection.is_visible_to(request.user):
user = request.user
if collection and not collection.is_visible_to(user):
raise PermissionDenied(_("Insufficient permission"))
if request.method == "GET":
return render(request, "collection_share.html", {"collection": collection})
else:
comment = request.POST.get("comment")
comment = request.POST.get("comment", "")
# boost if possible, otherwise quote
if (
not comment
and request.user.preference.mastodon_repost_mode == 0
and user.preference.mastodon_repost_mode == 0
and collection.latest_post
):
boost_toot_later(request.user, collection.latest_post.url)
if user.mastodon:
user.mastodon.boost_later(collection.latest_post.url)
else:
visibility = int(request.POST.get("visibility", default=0))
visibility = VisibilityType(request.POST.get("visibility", default=0))
link = (
collection.latest_post.url
if collection.latest_post
else collection.absolute_url
)
if not share_collection(
collection, comment, request.user, visibility, link
):
) or ""
if not share_collection(collection, comment, user, visibility, link):
return render_relogin(request)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
def share_collection(
collection: Collection,
comment: str,
user: User,
visibility: VisibilityType,
link: str,
):
if not user or not user.mastodon:
return
tags = (
"\n"
+ user.preference.mastodon_append_tag.replace("[category]", _("collection"))
if user.preference.mastodon_append_tag
else ""
)
user_str = (
_("shared my collection")
if user == collection.owner.user
else (
_("shared {username}'s collection").format(
username=(
" @" + collection.owner.user.mastodon_acct + " "
if collection.owner.user.mastodon_acct
else " " + collection.owner.username + " "
)
)
)
)
content = f"{user_str}:{collection.title}\n{link}\n{comment}{tags}"
response = user.mastodon.post(content, visibility)
if response is not None and response.status_code in [200, 201]:
return True
else:
return False
def collection_retrieve_items(
request: AuthedHttpRequest, collection_uuid, edit=False, msg=None
):

View file

@ -7,7 +7,6 @@ from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from common.utils import AuthedHttpRequest, get_uuid_or_404, target_identity_required
from mastodon.api import boost_toot_later
from takahe.utils import Takahe
from ..forms import *
@ -69,8 +68,8 @@ def post_boost(request: AuthedHttpRequest, post_id: int):
post = Takahe.get_post(post_id)
if not post:
raise BadRequest(_("Invalid parameter"))
if request.user.mastodon_site and request.user.preference.mastodon_repost_mode == 1:
boost_toot_later(request.user, post.object_uri)
if request.user.mastodon and request.user.preference.mastodon_repost_mode == 1:
request.user.mastodon.boost_later(post.object_uri)
else:
Takahe.boost_post(post_id, request.user.identity.pk)
return render(request, "action_boost_post.html", {"post": post})

View file

@ -19,7 +19,7 @@ from catalog.models import (
item_content_types,
)
from journal.models import Comment, ShelfType
from mastodon.api import boost_toot_later, get_toot_visibility, post_toot_later
from journal.models.common import VisibilityType
from takahe.utils import Takahe
from users.models import User
@ -115,7 +115,7 @@ class WrappedShareView(LoginRequiredMixin, TemplateView):
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
img = base64.b64decode(request.POST.get("img", ""))
comment = request.POST.get("comment", "")
visibility = int(request.POST.get("visibility", 0))
visibility = VisibilityType(request.POST.get("visibility", 0))
user: User = request.user # type: ignore
identity = user.identity
media = Takahe.upload_image(
@ -128,16 +128,11 @@ class WrappedShareView(LoginRequiredMixin, TemplateView):
attachments=[media],
)
classic_crosspost = user.preference.mastodon_repost_mode == 1
if classic_crosspost:
post_toot_later(
user,
comment,
get_toot_visibility(visibility, user),
img=img,
img_name="year.png",
img_type="image/png",
if classic_crosspost and user.mastodon:
user.mastodon.post(
comment, visibility, attachments=[("year.png", img, "image/png")]
)
elif post:
boost_toot_later(user, post.url)
elif post and user.mastodon:
user.mastodon.boost_later(post.url)
messages.add_message(request, messages.INFO, _("Summary posted to timeline."))
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

View file

@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-23 17:34-0400\n"
"POT-Creation-Date: 2024-07-01 17:19-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -15,15 +15,15 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: boofilsic/settings.py:394
#: boofilsic/settings.py:399
msgid "English"
msgstr "英语"
#: boofilsic/settings.py:395
#: boofilsic/settings.py:400
msgid "Simplified Chinese"
msgstr "简体中文"
#: boofilsic/settings.py:396
#: boofilsic/settings.py:401
msgid "Traditional Chinese"
msgstr "繁体中文"
@ -48,7 +48,7 @@ msgstr "译者"
#: catalog/book/models.py:116 catalog/movie/models.py:145
#: catalog/performance/models.py:128 catalog/performance/models.py:270
#: catalog/templates/edition.html:47 catalog/tv/models.py:214
#: catalog/tv/models.py:378 users/models/user.py:101
#: catalog/tv/models.py:378 users/models/user.py:96
msgid "language"
msgstr "语言"
@ -1032,7 +1032,7 @@ msgstr "创建"
#: journal/templates/collection_edit.html:38 journal/templates/comment.html:69
#: journal/templates/mark.html:147 journal/templates/note.html:39
#: journal/templates/review_edit.html:39 journal/templates/tag_edit.html:51
#: users/templates/users/account.html:43 users/templates/users/account.html:102
#: users/templates/users/account.html:47 users/templates/users/account.html:106
#: users/templates/users/preferences.html:187
#: users/templates/users/preferences.html:212
msgid "Save"
@ -1380,21 +1380,21 @@ msgstr "条目已不存在"
#: catalog/views_edit.py:122 catalog/views_edit.py:145
#: catalog/views_edit.py:197 catalog/views_edit.py:273
#: catalog/views_edit.py:352 journal/views/collection.py:52
#: journal/views/collection.py:102 journal/views/collection.py:114
#: journal/views/collection.py:130 journal/views/collection.py:161
#: journal/views/collection.py:180 journal/views/collection.py:200
#: journal/views/collection.py:212 journal/views/collection.py:226
#: journal/views/collection.py:240 journal/views/collection.py:243
#: journal/views/collection.py:267 journal/views/common.py:134
#: journal/views/post.py:21 journal/views/post.py:43 journal/views/review.py:32
#: catalog/views_edit.py:352 journal/views/collection.py:50
#: journal/views/collection.py:100 journal/views/collection.py:112
#: journal/views/collection.py:129 journal/views/collection.py:195
#: journal/views/collection.py:214 journal/views/collection.py:234
#: journal/views/collection.py:246 journal/views/collection.py:260
#: journal/views/collection.py:274 journal/views/collection.py:277
#: journal/views/collection.py:301 journal/views/common.py:134
#: journal/views/post.py:20 journal/views/post.py:42 journal/views/review.py:32
#: journal/views/review.py:46
msgid "Insufficient permission"
msgstr "权限不足"
#: catalog/views_edit.py:200 journal/views/collection.py:229
#: journal/views/collection.py:296 journal/views/common.py:81
#: journal/views/mark.py:141 journal/views/post.py:57 journal/views/post.py:71
#: catalog/views_edit.py:200 journal/views/collection.py:263
#: journal/views/collection.py:330 journal/views/common.py:81
#: journal/views/mark.py:141 journal/views/post.py:56 journal/views/post.py:70
#: journal/views/review.py:93 journal/views/review.py:96 users/views.py:169
msgid "Invalid parameter"
msgstr "无效参数"
@ -1470,7 +1470,7 @@ msgstr "开发者"
msgid "Source Code"
msgstr "源代码"
#: common/templates/_footer.html:16 users/templates/users/login.html:161
#: common/templates/_footer.html:16 users/templates/users/login.html:171
#, python-format
msgid "You are visiting an alternative domain for %(site_name)s, please always use <a href=\"%(site_url)s%(request.get_full_path)s\">original version</a> if possible."
msgstr "这是%(site_name)s的临时镜像请尽可能使用<a href=\"%(site_url)s%(request.get_full_path)s\">原始站点</a>。"
@ -1635,15 +1635,6 @@ msgstr ""
msgid "Error"
msgstr "错误"
#: common/templates/common/verify.html:19 users/account.py:104
#: users/account.py:485
msgid "Verification email is being sent, please check your inbox."
msgstr "验证邮件已发送,请查阅收件箱。"
#: common/templates/common/verify.html:21
msgid "Please click the login link in the email, or enter the verification code you received."
msgstr "请点击邮件中的登录链接,或输入收到的验证码。"
#: common/templatetags/duration.py:49
msgid "just now"
msgstr "刚刚"
@ -1721,7 +1712,7 @@ msgstr "{username} 的播客订阅"
msgid "note"
msgstr "备注"
#: journal/models/common.py:33 journal/templates/action_open_post.html:8
#: journal/models/common.py:32 journal/templates/action_open_post.html:8
#: journal/templates/action_open_post.html:14
#: journal/templates/action_open_post.html:16
#: journal/templates/collection_share.html:35 journal/templates/comment.html:35
@ -1732,7 +1723,7 @@ msgstr "备注"
msgid "Public"
msgstr "公开"
#: journal/models/common.py:34 journal/templates/action_open_post.html:10
#: journal/models/common.py:33 journal/templates/action_open_post.html:10
#: journal/templates/collection_share.html:46 journal/templates/comment.html:42
#: journal/templates/mark.html:100 journal/templates/wrapped_share.html:49
#: users/templates/users/data.html:55 users/templates/users/data.html:147
@ -1740,7 +1731,7 @@ msgstr "公开"
msgid "Followers Only"
msgstr "仅关注者"
#: journal/models/common.py:35 journal/templates/action_open_post.html:12
#: journal/models/common.py:34 journal/templates/action_open_post.html:12
#: journal/templates/collection_share.html:57 journal/templates/comment.html:49
#: journal/templates/mark.html:107 journal/templates/wrapped_share.html:55
#: users/templates/users/data.html:63 users/templates/users/data.html:155
@ -1748,69 +1739,70 @@ msgstr "仅关注者"
msgid "Mentioned Only"
msgstr "自己和提到的人"
#: journal/models/note.py:35
#: journal/models/note.py:34
msgid "Page"
msgstr "页码"
#: journal/models/note.py:36
#: journal/models/note.py:35
msgid "Chapter"
msgstr "章节"
#: journal/models/note.py:39
#: journal/models/note.py:38
msgid "Part"
msgstr "分部"
#: journal/models/note.py:40
#: journal/models/note.py:39
msgid "Episode"
msgstr "单集"
#: journal/models/note.py:41
#: journal/models/note.py:40
msgid "Track"
msgstr "曲目"
#: journal/models/note.py:42
#: journal/models/note.py:41
msgid "Cycle"
msgstr "周目"
#: journal/models/note.py:43
#: journal/models/note.py:42
msgid "Timestamp"
msgstr "时间戳"
#: journal/models/note.py:44
#: journal/models/note.py:43
msgid "Percentage"
msgstr "百分比"
#: journal/models/note.py:61
#: journal/models/note.py:60
#, python-brace-format
msgid "Page {value}"
msgstr "第{value}页"
#: journal/models/note.py:62
#: journal/models/note.py:61
#, python-brace-format
msgid "Chapter {value}"
msgstr "第{value}章"
#: journal/models/note.py:65
#: journal/models/note.py:64
#, python-brace-format
msgid "Part {value}"
msgstr "第{value}部"
#: journal/models/note.py:66
#: journal/models/note.py:65
#, python-brace-format
msgid "Episode {value}"
msgstr "第{value}集"
#: journal/models/note.py:67
#: journal/models/note.py:66
#, python-brace-format
msgid "Track {value}"
msgstr "第{value}首"
#: journal/models/note.py:68
#: journal/models/note.py:67
#, python-brace-format
msgid "Cycle {value}"
msgstr "{value}周目"
#: journal/models/renderers.py:94 mastodon/api.py:619 takahe/utils.py:550
#: journal/models/renderers.py:94 mastodon/models/mastodon.py:441
#: takahe/utils.py:550
#, python-brace-format
msgid "regarding {item_title}, may contain spoiler or triggering content"
msgstr "关于 {item_title},可能包含剧透或敏感内容"
@ -2537,7 +2529,7 @@ msgstr "日历"
msgid "annual summary"
msgstr "年度小结"
#: journal/templates/profile.html:131 mastodon/api.py:678
#: journal/templates/profile.html:131 journal/views/collection.py:165
msgid "collection"
msgstr "收藏单"
@ -2655,16 +2647,25 @@ msgid_plural "%(count)d items"
msgstr[0] "%(count)d 个条目"
msgstr[1] "%(count)d 个条目"
#: journal/views/collection.py:38
#: journal/views/collection.py:36
#, python-brace-format
msgid "Collection by {0}"
msgstr "{0} 的收藏单"
#: journal/views/collection.py:190
#: journal/views/collection.py:170
msgid "shared my collection"
msgstr "分享我的收藏单"
#: journal/views/collection.py:173
#, python-brace-format
msgid "shared {username}'s collection"
msgstr "分享 {username} 的收藏单"
#: journal/views/collection.py:224
msgid "Unable to find the item, please use item url from this site."
msgstr "找不到条目,请使用本站条目网址。"
#: journal/views/collection.py:303 journal/views/collection.py:324
#: journal/views/collection.py:337 journal/views/collection.py:358
#: journal/views/review.py:124
msgid "Login required"
msgstr "登录后访问"
@ -2710,7 +2711,7 @@ msgstr "进度类型(选填)"
msgid "Invalid form data"
msgstr "无效表单信息。"
#: journal/views/post.py:41
#: journal/views/post.py:40
msgid "Post not found"
msgstr "帖文未找到"
@ -2748,55 +2749,120 @@ msgstr "重复标签"
msgid "Tag updated."
msgstr "标签已更新"
#: journal/views/wrapped.py:142
#: journal/views/wrapped.py:137
msgid "Summary posted to timeline."
msgstr "总结已发布到时间轴"
#: mastodon/api.py:683
msgid "shared my collection"
msgstr "分享我的收藏单"
#: mastodon/models/common.py:12 users/templates/users/login.html:57
msgid "Email"
msgstr "电子邮件"
#: mastodon/api.py:686
#: mastodon/models/common.py:13
msgid "Mastodon"
msgstr "Mastodon"
#: mastodon/models/common.py:14 users/templates/users/login.html:75
msgid "Threads"
msgstr "Threads"
#: mastodon/models/common.py:15 users/templates/users/login.html:80
msgid "Bluesky"
msgstr "Bluesky"
#: mastodon/models/email.py:51
msgid ""
"\n"
"\n"
"If you did not mean to register or login, please ignore this email. If you are concerned with your account security, please change the email linked with your account, or contact us."
msgstr ""
"\n"
"\n"
"如果你没有打算用此电子邮件地址注册或登录本站,请忽略此邮件;如果你确信账号存在安全风险,请更改注册邮件地址或与我们联系。"
#: mastodon/models/email.py:58
#, python-brace-format
msgid "shared {username}'s collection"
msgstr "分享 {username} 的收藏单"
msgid ""
"Use this code to verify your email address {email}\n"
"\n"
"{code}"
msgstr ""
"你好,\n"
"请用以下代码验证你的电子邮件地址 {email}\n"
"\n"
"{code}"
#: mastodon/models.py:7
#: mastodon/models/email.py:62
#, python-brace-format
msgid ""
"Use this code to login as {email}\n"
"\n"
"{code}"
msgstr ""
"你好,\n"
"请输入如下验证码登录{email}账号:\n"
"\n"
"{code}\n"
#: mastodon/models/email.py:68
#, python-brace-format
msgid ""
"There is no account registered with this email address yet: {email}\n"
"\n"
"If you already have an account with us, just login and add this email to you account.\n"
"\n"
"If you prefer to register a new account with this email, please use this verification code: {code}"
msgstr ""
"你好,\n"
"本站没有与{email}关联的账号。你希望注册一个新账号吗?\n"
"\n"
"如果你已注册过本站或某个联邦宇宙(长毛象)实例,不必重新注册,只要用联邦宇宙身份登录本站,再关联这个电子邮件地址,即可通过邮件登录。\n"
"\n"
"如果你确认要使用电子邮件新注册账号,请输入如下验证码: {code}"
#: mastodon/models/mastodon.py:544
msgid "site domain name"
msgstr "实例域名"
msgstr "站点域名"
#: mastodon/models.py:8
#: mastodon/models/mastodon.py:545
msgid "domain for api call"
msgstr "实例API域名"
msgstr "站点API域名"
#: mastodon/models.py:9
#: mastodon/models/mastodon.py:546
msgid "type and verion"
msgstr "实例版本"
msgstr "站点类型和版本"
#: mastodon/models.py:10
#: mastodon/models/mastodon.py:547
msgid "in-site app id"
msgstr "实例应用id"
#: mastodon/models.py:11
#: mastodon/models/mastodon.py:548
msgid "client id"
msgstr "实例应用Client ID"
#: mastodon/models.py:12
#: mastodon/models/mastodon.py:549
msgid "client secret"
msgstr "实例应用Client Secret"
#: mastodon/models.py:13
#: mastodon/models/mastodon.py:550
msgid "vapid key"
msgstr "实例应用VAPID Key"
#: mastodon/models.py:15
#: mastodon/models/mastodon.py:552
msgid "0: custom emoji; 1: unicode moon; 2: text"
msgstr "实例表情模式"
#: mastodon/models.py:18
#: mastodon/models/mastodon.py:555
msgid "max toot len"
msgstr "帖文长度限制"
#: mastodon/models/mastodon.py:617
msgid "Boost"
msgstr "转播"
#: mastodon/models/mastodon.py:618
msgid "New Post"
msgstr "新帖文"
#: social/templates/activity/comment_child_item.html:12
#: social/templates/feed_events.html:40
msgid "play"
@ -3053,171 +3119,97 @@ msgstr "头像"
msgid "Header picture"
msgstr "背景图片"
#: users/account.py:84
#: users/account.py:72
msgid "Invalid email address"
msgstr "无效的电子邮件地址"
#: users/account.py:102
#: users/account.py:79
msgid "Verification"
msgstr "验证"
#: users/account.py:119
#: users/account.py:81 users/templates/users/verify.html:19
msgid "Verification email is being sent, please check your inbox."
msgstr "验证邮件已发送,请查阅收件箱。"
#: users/account.py:96
msgid "Missing instance domain"
msgstr "未指定实例域名"
#: users/account.py:140
#: users/account.py:111
msgid "Error connecting to instance"
msgstr "无法连接实例"
#: users/account.py:155 users/account.py:165 users/account.py:178
#: users/account.py:201 users/account.py:226
#: users/account.py:126 users/account.py:136 users/account.py:149
#: users/account.py:164 users/account.py:175 users/account.py:198
#: users/account.py:313 users/account.py:340
msgid "Authentication failed"
msgstr "认证失败"
#: users/account.py:156
#: users/account.py:127
msgid "Invalid response from Fediverse instance."
msgstr "联邦实例返回信息无效"
#: users/account.py:166
#: users/account.py:137
msgid "Invalid cookie data."
msgstr "无效cookie信息。"
#: users/account.py:172
#: users/account.py:143
msgid "Invalid instance domain"
msgstr "实例域名无效"
#: users/account.py:179
#: users/account.py:150
msgid "Invalid token from Fediverse instance."
msgstr "联邦实例返回了无效的认证令牌。"
#: users/account.py:202 users/account.py:554
#: users/account.py:165 users/account.py:459
msgid "Invalid account data from Fediverse instance."
msgstr "联邦实例返回了无效的账号数据。"
#: users/account.py:227
#: users/account.py:176 users/account.py:341
msgid "Invalid user."
msgstr "无效用户。"
#: users/account.py:199
msgid "Registration is for invitation only"
msgstr "本站仅限邀请注册"
#: users/account.py:286
#: users/account.py:265
msgid "This username is already in use."
msgstr "用户名已被使用"
#: users/account.py:297
#: users/account.py:276
msgid "This email address is already in use."
msgstr "此电子邮件地址已被使用"
#: users/account.py:305
msgid ""
"\n"
"\n"
"If you did not mean to register or login, please ignore this email. If you are concerned with your account security, please change the email linked with your account, or contact us."
msgstr ""
"\n"
"\n"
"如果你没有打算用此电子邮件地址注册或登录本站,请忽略此邮件;如果你确信账号存在安全风险,请更改注册邮件地址或与我们联系。"
#: users/account.py:311
#, python-brace-format
msgid ""
"Click this link to verify your email address {email}\n"
"{url}"
msgstr ""
"你好,\n"
"请点击以下链接验证你的电子邮件地址 {email}\n"
"{url}"
#: users/account.py:319
#, python-brace-format
msgid ""
"Use this code to confirm login as {email}\n"
"\n"
"{code}\n"
"\n"
"Or click this link to login\n"
"{url}"
msgstr ""
"你好,\n"
"请在登录界面输入如下验证码:\n"
"\n"
"{code}\n"
"\n"
"或点击以下链接登录{email}账号\n"
"{url}"
#: users/account.py:326
#, python-brace-format
msgid ""
"There is no account registered with this email address yet.{email}\n"
"\n"
"If you already have an account with a Fediverse identity, just login and add this email to you account.\n"
"\n"
msgstr ""
"你好,\n"
"本站没有与{email}关联的账号。你希望注册一个新账号吗?\n"
"\n"
"如果你已注册过本站或某个联邦宇宙(长毛象)实例,不必重新注册,只要用联邦宇宙身份登录本站,再关联这个电子邮件地址,即可通过邮件登录。\n"
"\n"
"如果你还没有联邦宇宙身份,可以访问这里选择实例并创建一个: https://joinmastodon.org/zh/servers\n"
#: users/account.py:330
#, python-brace-format
msgid ""
"\n"
"If you prefer to register a new account, please use this code: {code}\n"
"Or click this link:\n"
"{url}"
msgstr ""
"\n"
"如果你确定使用电子邮件注册一个新账号,可以输入这个验证码\n"
"{code}\n"
"或者点击以下链接\n"
"{url}"
#: users/account.py:357 users/account.py:366
#: users/account.py:290 users/account.py:299
msgid "Invalid verification code"
msgstr "验证码无效或已过期"
#: users/account.py:385
msgid "Invalid verification link"
msgstr "验证链接无效或已过期"
#: users/account.py:402 users/account.py:408
msgid "Email mismatch"
msgstr "电子邮件地址不匹配"
#: users/account.py:412 users/account.py:464
msgid "Email in use"
#: users/account.py:314
msgid "Email already in use"
msgstr "电子邮件地址已被注册"
#: users/account.py:417
msgid "Unable to verify"
msgstr "无法完成验证"
#: users/account.py:382
msgid "Valid username required"
msgstr "请输入有效的用户名"
#: users/account.py:449
#: users/account.py:386
msgid "Username in use"
msgstr "用户名已被使用"
#: users/account.py:490
msgid "Username all set."
msgstr "用户名已设置。"
#: users/account.py:493
msgid "Email removed from account."
msgstr "电子邮件地址已取消关联。"
#: users/account.py:514
#: users/account.py:418
msgid "Unable to update login information: identical identity."
msgstr "无法更新登录信息:该身份与当前账号相同。"
#: users/account.py:524
#: users/account.py:424
msgid "Unable to update login information: identity in use."
msgstr "无法更新登录信息:该身份已被其它账号使用。"
#: users/account.py:550
#: users/account.py:455
msgid "Login information updated."
msgstr "登录信息已更新"
#: users/account.py:604
#: users/account.py:508
msgid "Account mismatch."
msgstr "账号信息不匹配。"
@ -3273,27 +3265,27 @@ msgstr "完成"
msgid "Failed"
msgstr "失败"
#: users/models/user.py:48
#: users/models/user.py:50
msgid "Enter a valid username. This value may contain only unaccented lowercase a-z and uppercase A-Z letters, numbers, and _ characters."
msgstr "输入用户名限英文字母数字下划线最多30个字符。"
#: users/models/user.py:81
#: users/models/user.py:85
msgid "username"
msgstr "用户名"
#: users/models/user.py:85
#: users/models/user.py:89
msgid "Required. 50 characters or fewer. Letters, digits and _ only."
msgstr "必填限英文字母数字下划线最多30个字符。"
#: users/models/user.py:88
#: users/models/user.py:92
msgid "A user with that username already exists."
msgstr "使用该用户名的用户已存在。"
#: users/models/user.py:92
#: users/models/user.py:105
msgid "email address"
msgstr "电子邮件地址"
#: users/models/user.py:98
#: users/models/user.py:111
msgid "email address pending verification"
msgstr "待验证的电子邮件地址"
@ -3309,128 +3301,132 @@ msgstr "用户名、电子邮件与社交身份"
msgid "Username"
msgstr "用户名"
#: users/templates/users/account.html:27 users/templates/users/register.html:31
#: users/templates/users/account.html:27 users/templates/users/register.html:23
msgid "2-30 alphabets, numbers or underscore, can't be changed once saved"
msgstr "2-30个字符限英文字母数字下划线保存后不可更改"
#: users/templates/users/account.html:30 users/templates/users/register.html:35
msgid "email address (optional if you log in via other Fediverse site, but recommended)"
msgstr "以及作为备用登录方式的电子邮件地址(推荐)"
#: users/templates/users/account.html:30 users/templates/users/register.html:27
msgid "Email address"
msgstr "电子邮件地址"
#: users/templates/users/account.html:38 users/templates/users/register.html:43
#: users/templates/users/account.html:39 users/templates/users/register.html:36
#, python-format
msgid "Please click the confirmation link in the email sent to %(pending_email)s; if you haven't received it for more than a few minutes, please input and save again."
msgstr "当前待确认的电子邮件地址为%(pending_email)s请查收邮件并点击确认链接如长时间未收到可重新输入并保存。"
#: users/templates/users/account.html:50
#: users/templates/users/account.html:41 users/templates/users/register.html:39
msgid "Email is recommended as a backup login method, if you log in via a Fediverse instance"
msgstr "推荐输入电子邮件地址作为备用登录方式。"
#: users/templates/users/account.html:54
msgid "Associated identities"
msgstr "已关联社交身份"
#: users/templates/users/account.html:58
#: users/templates/users/account.html:62
msgid "If you have not yet registered with any Federated instance, you may <a href=\"https://joinmastodon.org/zh/servers\" target=\"_blank\">choose an instance</a> and register."
msgstr "如果你还没有在任何<em data-tooltip=\"联邦宇宙(Fediverse 有时也被称为长毛象)是一种分布式社交网络\">联邦宇宙</em>实例注册过,可先<a href=\"https://joinmastodon.org/zh/servers\" target=\"_blank\">选择实例并注册</a>。"
#: users/templates/users/account.html:63
#: users/templates/users/account.html:67
msgid "To associate with another federated identity, please enter the domain name of the instance where the new identity is located."
msgstr "如需关联到另一个联邦宇宙社交身份,请输入新身份所在的实例域名"
#: users/templates/users/account.html:65
#: users/templates/users/account.html:69
msgid "If you have registered with a Federated instance, please enter the instance domain name."
msgstr "如果你已经注册过联邦宇宙实例,请输入实例域名"
#: users/templates/users/account.html:74
#: users/templates/users/account.html:78
msgid "Go to target instance and authorize with the identity"
msgstr "登录实例并关联"
#: users/templates/users/account.html:79
#: users/templates/users/account.html:83
msgid "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."
msgstr "替换关联后可使用新的联邦宇宙身份来登录本站和控制数据可见性,已有的标记评论收藏单等数据不受影响。"
#: users/templates/users/account.html:81
#: users/templates/users/account.html:85
msgid "Once associated with Fediverse identity, you can discover more users and use the full features of this site."
msgstr "关联联邦宇宙身份后可发现更多用户,并使用本站完整功能。"
#: users/templates/users/account.html:91
#: users/templates/users/account.html:95
msgid "Display name, avatar and other information"
msgstr "昵称、头像与其它个人信息"
#: users/templates/users/account.html:94
#: users/templates/users/account.html:98
msgid "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?"
msgstr "在这里更新个人资料会停止从关联实例自动同步昵称等个人信息,确定继续吗?"
#: users/templates/users/account.html:108
#: users/templates/users/account.html:112
msgid "Users you are following"
msgstr "正在关注的用户"
#: users/templates/users/account.html:114
#: users/templates/users/account.html:118
msgid "Users who follow you"
msgstr "关注了你的用户"
#: users/templates/users/account.html:120
#: users/templates/users/account.html:124
msgid "Users who request to follow you"
msgstr "请求关注你的用户"
#: users/templates/users/account.html:126
#: users/templates/users/account.html:130
msgid "Users you are muting"
msgstr "已隐藏的用户"
#: users/templates/users/account.html:132
#: users/templates/users/account.html:136
msgid "Users you are blocking"
msgstr "已屏蔽的用户"
#: users/templates/users/account.html:138
#: users/templates/users/account.html:142
msgid "Sync and import social account"
msgstr "同步联邦宇宙信息和社交数据"
#: users/templates/users/account.html:148
#: users/templates/users/account.html:152
msgid "Sync display name, bio and avatar"
msgstr "自动同步用户昵称等基本信息"
#: users/templates/users/account.html:156
#: users/templates/users/account.html:160
msgid "Sync follow, mute and block"
msgstr "自动导入新增的关注、屏蔽和隐藏列表"
#: users/templates/users/account.html:160
#: users/templates/users/account.html:164
msgid "Save sync settings"
msgstr "保存同步设置"
#: users/templates/users/account.html:163
#: users/templates/users/account.html:167
msgid "New follow, mute and blocks in the associated identity may be automatically imported; removal has to be done manually."
msgstr "本站会按照以上设置每天自动导入你在联邦宇宙实例中新增的关注、屏蔽和隐藏列表如果你在联邦宇宙实例中关注的用户加入了NeoDB你会自动关注她如果你在联邦宇宙实例中取消了关注、屏蔽或隐藏本站不会自动取消但你可以手动移除。"
#: users/templates/users/account.html:170
#: users/templates/users/account.html:174
msgid "Click button below to start sync now."
msgstr "如果希望立即开始同步,可以点击下方按钮。"
#: users/templates/users/account.html:172
#: users/templates/users/account.html:176
msgid "Sync now"
msgstr "立即同步"
#: users/templates/users/account.html:176
#: users/templates/users/account.html:180
msgid "Last updated"
msgstr "最近更新"
#: users/templates/users/account.html:184
#: users/templates/users/account.html:188
msgid "Delete Account"
msgstr "删除账号"
#: users/templates/users/account.html:187
#: users/templates/users/account.html:191
msgid "Once deleted, account data cannot be recovered. Sure to proceed?"
msgstr "账号数据一旦删除后将无法恢复,确定继续吗?"
#: users/templates/users/account.html:190
#: users/templates/users/account.html:194
msgid "Enter full <code>username@instance.social</code> or <code>email@domain.com</code> to confirm deletion."
msgstr "输入完整的登录用 <code>用户名@实例名</code> 或 <code>电子邮件地址</code> 以确认删除"
#: users/templates/users/account.html:200
#: users/templates/users/account.html:204
msgid "Once deleted, account data cannot be recovered."
msgstr "账号数据一旦删除后将无法恢复"
#: users/templates/users/account.html:202
#: users/templates/users/account.html:206
msgid "Importing in progress, can't delete now."
msgstr "暂时无法删除,因为有导入任务正在进行"
#: users/templates/users/account.html:205
#: users/templates/users/account.html:209
msgid "Permanently Delete"
msgstr "永久删除"
@ -3557,6 +3553,7 @@ msgid "Searching the fediverse"
msgstr "正在搜索联邦宇宙"
#: users/templates/users/login.html:16 users/templates/users/register.html:8
#: users/templates/users/welcome.html:8
msgid "Register"
msgstr "注册"
@ -3569,85 +3566,73 @@ msgstr "登录"
msgid "back to your home page."
msgstr "返回首页"
#: users/templates/users/login.html:56
msgid "Email"
msgstr "电子邮件"
#: users/templates/users/login.html:61
#: users/templates/users/login.html:63
msgid "Fediverse (Mastodon)"
msgstr "联邦宇宙(有时也被称为长毛象)"
#: users/templates/users/login.html:71
msgid "Threads"
msgstr "Threads"
#: users/templates/users/login.html:74
msgid "Bluesky"
msgstr "Bluesky"
#: users/templates/users/login.html:89
#: users/templates/users/login.html:97
msgid "Enter your email address"
msgstr "输入电子邮件地址"
#: users/templates/users/login.html:91
#: users/templates/users/login.html:99
msgid "Verify Email"
msgstr "验证电子邮件"
#: users/templates/users/login.html:106
msgid "domain of your instance, e.g. mastodon.social"
#: users/templates/users/login.html:114
msgid "Domain of your instance, e.g. mastodon.social"
msgstr "实例域名(不含@和@之前的部分)如mastodon.social"
#: users/templates/users/login.html:112
#: users/templates/users/login.html:120
msgid "Please enter domain of your instance; e.g. if your id is <i>@neodb@mastodon.social</i>, only enter <i>mastodon.social</i>."
msgstr "请输入你的实例域名(不含@和@之前的部分);如果你的联邦账号是<i>@neodb@mastodon.social</i>只需要在此输入<i>mastodon.social</i>。"
msgstr "请输入你的实例域名(不含@和@之前的部分);如果你的联邦账号是<i>@neodb@mastodon.social</i>只需要在此输入<i>mastodon.social</i>。"
#: users/templates/users/login.html:115 users/templates/users/login.html:145
#: users/templates/users/login.html:123 users/templates/users/login.html:154
msgid "Authorize via Fediverse instance"
msgstr "去联邦实例授权注册或登录"
#: users/templates/users/login.html:117
#: users/templates/users/login.html:125
msgid "If you don't have a <a href=\"https://joinmastodon.org/servers\" target=\"_blank\">Fediverse (Mastodon) account</a> yet, you may register or login with Email first, and link it with Fediverse (Mastodon) later in account settings."
msgstr "如果你还没有或不便注册<a href=\"https://joinmastodon.org/servers\" target=\"_blank\">联邦实例</a>账号,也可先通过电子邮件或其它平台注册登录,未来再作关联。"
#: users/templates/users/login.html:127
#: users/templates/users/login.html:135
msgid "Authorize via Threads"
msgstr "去Threads授权注册或登录"
#: users/templates/users/login.html:128
#: users/templates/users/login.html:136
msgid "If you have already account here registered via a different way, you may login through there and link with your Threads account in account settings."
msgstr "如果你已通过其它方式注册过本站帐号请用该方式登录后再关联Threads。"
#: users/templates/users/login.html:137
#: users/templates/users/login.html:145
msgid "Authorize via Bluesky"
msgstr "去Bluesky授权注册或登录"
#: users/templates/users/login.html:138
#: users/templates/users/login.html:146
msgid "If you have already account here registered via a different way, you may login through there and link with your Bluesky account in account settings."
msgstr "如果你已通过其它方式注册过本站帐号请用该方式登录后再关联Bluesky。"
#: users/templates/users/login.html:151
#: users/templates/users/login.html:161
msgid "Valid invitation code, please login or register."
msgstr "邀请链接有效,可注册新用户"
#: users/templates/users/login.html:153
#: users/templates/users/login.html:163
msgid "Please use invitation link to register a new account; existing user may login."
msgstr "本站目前为邀请注册,已有账户可直接登入,新用户请使用有效邀请链接注册"
#: users/templates/users/login.html:155
#: users/templates/users/login.html:165
msgid "Invitation code invalid or expired."
msgstr "邀请链接无效,已有账户可直接登入,新用户请使用有效邀请链接注册"
#: users/templates/users/login.html:163
#: users/templates/users/login.html:173
msgid "Loading timed out, please check your network (VPN) settings."
msgstr "部分模块加载超时,请检查网络(翻墙)设置。"
#: users/templates/users/login.html:169
msgid "Using this site implies consent of our <a href=\"/pages/rules/\">rules</a> and <a href=\"/pages/terms/\">terms</a>, and use of cookies to provide necessary functionality."
#: users/templates/users/login.html:179
msgid "Continue using this site implies consent to our <a href=\"/pages/rules/\">rules</a> and <a href=\"/pages/terms/\">terms</a>, including using cookies to provide necessary functionality."
msgstr "继续访问或注册视为同意<a href=\"/pages/rules/\">站规</a>与<a href=\"/pages/terms/\">协议</a>及使用cookie提供必要功能"
#: users/templates/users/login.html:175
msgid "select or input domain name of your instance (excl. @)"
msgstr "输入或选择实例域名(不含@和@之前的部分)"
#: users/templates/users/login.html:185
msgid "Domain of your instance (excl. @)"
msgstr "实例域名(不含@和@之前的部分)"
#: users/templates/users/preferences.html:26
msgid "Default view once logged in"
@ -3857,34 +3842,16 @@ msgstr "点击可屏蔽"
msgid "sure to block?"
msgstr "确定屏蔽该用户吗?"
#: users/templates/users/register.html:18
msgid "Welcome"
msgstr "欢迎"
#: users/templates/users/register.html:20
#, python-format
msgid ""
"\n"
" %(site_name)s is flourishing because of collaborations and contributions from users like you. Please read our <a href=\"/pages/terms\">term of service</a>, and feel free to <a href=\"%(support_link)s\">contact us</a> if you have any question or feedback.\n"
" "
msgstr ""
"\n"
"%(site_name)s还在不断完善中。 丰富的内容需要大家共同创造,试图添加垃圾数据(如添加信息混乱或缺失的书籍、以推广为主要目的的评论)将会受到严肃处理。 本站为非盈利站点cookie和其它数据保管使用原则请参阅<a href=\"/pages/terms\">站内公告</a>。 本站提供API和导出功能请妥善备份您的数据使用过程中遇到的问题或者错误欢迎向<a href=\"%(support_link)s\">维护者</a>提出。感谢理解和支持!"
#: users/templates/users/register.html:30
#: users/templates/users/register.html:22
#, python-format
msgid "Your username on %(site_name)s"
msgstr "你在%(site_name)s使用的用户名"
#: users/templates/users/register.html:49
#: users/templates/users/register.html:50
msgid "Confirm and save"
msgstr "确认并保存"
#: users/templates/users/register.html:50
msgid "Once saved, click the confirmation link in the email you receive"
msgstr "设置后请查收邮件并点击其中的确认链接"
#: users/templates/users/register.html:54
#: users/templates/users/register.html:54 users/templates/users/welcome.html:24
msgid "Cut the sh*t and get me in!"
msgstr ""
@ -3896,6 +3863,10 @@ msgstr "导出"
msgid "You may download the list here."
msgstr "此处可导出你在本站的关系列表。"
#: users/templates/users/verify.html:21
msgid "Please enter the verification code you received."
msgstr "请输入收到的验证码。"
#: users/templates/users/verify_email.html:8
#: users/templates/users/verify_email.html:17
msgid "Verify Your Email"
@ -3908,3 +3879,17 @@ msgstr "验证成功"
#: users/templates/users/verify_email.html:27
msgid "login again"
msgstr "重新登录"
#: users/templates/users/welcome.html:17
msgid "Welcome"
msgstr "欢迎"
#: users/templates/users/welcome.html:19
#, python-format
msgid ""
"\n"
" %(site_name)s is flourishing because of collaborations and contributions from users like you. Please read our <a href=\"/pages/terms\">term of service</a>, and feel free to <a href=\"%(support_link)s\">contact us</a> if you have any question or feedback.\n"
" "
msgstr ""
"\n"
"%(site_name)s还在不断完善中。 丰富的内容需要大家共同创造,试图添加垃圾数据(如添加信息混乱或缺失的书籍、以推广为主要目的的评论)将会受到严肃处理。 本站为非盈利站点cookie和其它数据保管使用原则请参阅<a href=\"/pages/terms\">站内公告</a>。 本站提供API和导出功能请妥善备份您的数据使用过程中遇到的问题或者错误欢迎向<a href=\"%(support_link)s\">维护者</a>提出。感谢理解和支持!"

View file

@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-23 17:34-0400\n"
"POT-Creation-Date: 2024-07-01 17:19-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -15,15 +15,15 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: boofilsic/settings.py:394
#: boofilsic/settings.py:399
msgid "English"
msgstr "英語"
#: boofilsic/settings.py:395
#: boofilsic/settings.py:400
msgid "Simplified Chinese"
msgstr "簡體中文"
#: boofilsic/settings.py:396
#: boofilsic/settings.py:401
msgid "Traditional Chinese"
msgstr "繁體中文"
@ -48,7 +48,7 @@ msgstr "譯者"
#: catalog/book/models.py:116 catalog/movie/models.py:145
#: catalog/performance/models.py:128 catalog/performance/models.py:270
#: catalog/templates/edition.html:47 catalog/tv/models.py:214
#: catalog/tv/models.py:378 users/models/user.py:101
#: catalog/tv/models.py:378 users/models/user.py:96
msgid "language"
msgstr "語言"
@ -1032,7 +1032,7 @@ msgstr "創建"
#: journal/templates/collection_edit.html:38 journal/templates/comment.html:69
#: journal/templates/mark.html:147 journal/templates/note.html:39
#: journal/templates/review_edit.html:39 journal/templates/tag_edit.html:51
#: users/templates/users/account.html:43 users/templates/users/account.html:102
#: users/templates/users/account.html:47 users/templates/users/account.html:106
#: users/templates/users/preferences.html:187
#: users/templates/users/preferences.html:212
msgid "Save"
@ -1380,21 +1380,21 @@ msgstr "條目已不存在"
#: catalog/views_edit.py:122 catalog/views_edit.py:145
#: catalog/views_edit.py:197 catalog/views_edit.py:273
#: catalog/views_edit.py:352 journal/views/collection.py:52
#: journal/views/collection.py:102 journal/views/collection.py:114
#: journal/views/collection.py:130 journal/views/collection.py:161
#: journal/views/collection.py:180 journal/views/collection.py:200
#: journal/views/collection.py:212 journal/views/collection.py:226
#: journal/views/collection.py:240 journal/views/collection.py:243
#: journal/views/collection.py:267 journal/views/common.py:134
#: journal/views/post.py:21 journal/views/post.py:43 journal/views/review.py:32
#: catalog/views_edit.py:352 journal/views/collection.py:50
#: journal/views/collection.py:100 journal/views/collection.py:112
#: journal/views/collection.py:129 journal/views/collection.py:195
#: journal/views/collection.py:214 journal/views/collection.py:234
#: journal/views/collection.py:246 journal/views/collection.py:260
#: journal/views/collection.py:274 journal/views/collection.py:277
#: journal/views/collection.py:301 journal/views/common.py:134
#: journal/views/post.py:20 journal/views/post.py:42 journal/views/review.py:32
#: journal/views/review.py:46
msgid "Insufficient permission"
msgstr "權限不足"
#: catalog/views_edit.py:200 journal/views/collection.py:229
#: journal/views/collection.py:296 journal/views/common.py:81
#: journal/views/mark.py:141 journal/views/post.py:57 journal/views/post.py:71
#: catalog/views_edit.py:200 journal/views/collection.py:263
#: journal/views/collection.py:330 journal/views/common.py:81
#: journal/views/mark.py:141 journal/views/post.py:56 journal/views/post.py:70
#: journal/views/review.py:93 journal/views/review.py:96 users/views.py:169
msgid "Invalid parameter"
msgstr "無效參數"
@ -1470,7 +1470,7 @@ msgstr "開發者"
msgid "Source Code"
msgstr "源代碼"
#: common/templates/_footer.html:16 users/templates/users/login.html:161
#: common/templates/_footer.html:16 users/templates/users/login.html:171
#, python-format
msgid "You are visiting an alternative domain for %(site_name)s, please always use <a href=\"%(site_url)s%(request.get_full_path)s\">original version</a> if possible."
msgstr "這是%(site_name)s的臨時鏡像請儘可能使用<a href=\"%(site_url)s%(request.get_full_path)s\">原始站點</a>。"
@ -1635,15 +1635,6 @@ msgstr ""
msgid "Error"
msgstr "錯誤"
#: common/templates/common/verify.html:19 users/account.py:104
#: users/account.py:485
msgid "Verification email is being sent, please check your inbox."
msgstr "驗證郵件已發送,請查閱收件箱。"
#: common/templates/common/verify.html:21
msgid "Please click the login link in the email, or enter the verification code you received."
msgstr "請點擊郵件中的登錄鏈接,或輸入收到的驗證碼。"
#: common/templatetags/duration.py:49
msgid "just now"
msgstr "剛剛"
@ -1721,7 +1712,7 @@ msgstr "{username} 的播客訂閱"
msgid "note"
msgstr "備註"
#: journal/models/common.py:33 journal/templates/action_open_post.html:8
#: journal/models/common.py:32 journal/templates/action_open_post.html:8
#: journal/templates/action_open_post.html:14
#: journal/templates/action_open_post.html:16
#: journal/templates/collection_share.html:35 journal/templates/comment.html:35
@ -1732,7 +1723,7 @@ msgstr "備註"
msgid "Public"
msgstr "公開"
#: journal/models/common.py:34 journal/templates/action_open_post.html:10
#: journal/models/common.py:33 journal/templates/action_open_post.html:10
#: journal/templates/collection_share.html:46 journal/templates/comment.html:42
#: journal/templates/mark.html:100 journal/templates/wrapped_share.html:49
#: users/templates/users/data.html:55 users/templates/users/data.html:147
@ -1740,7 +1731,7 @@ msgstr "公開"
msgid "Followers Only"
msgstr "僅關注者"
#: journal/models/common.py:35 journal/templates/action_open_post.html:12
#: journal/models/common.py:34 journal/templates/action_open_post.html:12
#: journal/templates/collection_share.html:57 journal/templates/comment.html:49
#: journal/templates/mark.html:107 journal/templates/wrapped_share.html:55
#: users/templates/users/data.html:63 users/templates/users/data.html:155
@ -1748,69 +1739,70 @@ msgstr "僅關注者"
msgid "Mentioned Only"
msgstr "自己和提到的人"
#: journal/models/note.py:35
#: journal/models/note.py:34
msgid "Page"
msgstr "頁碼"
#: journal/models/note.py:36
#: journal/models/note.py:35
msgid "Chapter"
msgstr "章節"
#: journal/models/note.py:39
#: journal/models/note.py:38
msgid "Part"
msgstr "分部"
#: journal/models/note.py:40
#: journal/models/note.py:39
msgid "Episode"
msgstr "單集"
#: journal/models/note.py:41
#: journal/models/note.py:40
msgid "Track"
msgstr "曲目"
#: journal/models/note.py:42
#: journal/models/note.py:41
msgid "Cycle"
msgstr "周目"
#: journal/models/note.py:43
#: journal/models/note.py:42
msgid "Timestamp"
msgstr "時間戳"
#: journal/models/note.py:44
#: journal/models/note.py:43
msgid "Percentage"
msgstr "百分比"
#: journal/models/note.py:61
#: journal/models/note.py:60
#, python-brace-format
msgid "Page {value}"
msgstr "第{value}頁"
#: journal/models/note.py:62
#: journal/models/note.py:61
#, python-brace-format
msgid "Chapter {value}"
msgstr "第{value}章"
#: journal/models/note.py:65
#: journal/models/note.py:64
#, python-brace-format
msgid "Part {value}"
msgstr "第{value}部"
#: journal/models/note.py:66
#: journal/models/note.py:65
#, python-brace-format
msgid "Episode {value}"
msgstr "第{value}集"
#: journal/models/note.py:67
#: journal/models/note.py:66
#, python-brace-format
msgid "Track {value}"
msgstr "第{value}首"
#: journal/models/note.py:68
#: journal/models/note.py:67
#, python-brace-format
msgid "Cycle {value}"
msgstr "{value}周目"
#: journal/models/renderers.py:94 mastodon/api.py:619 takahe/utils.py:550
#: journal/models/renderers.py:94 mastodon/models/mastodon.py:441
#: takahe/utils.py:550
#, python-brace-format
msgid "regarding {item_title}, may contain spoiler or triggering content"
msgstr "關於 {item_title},可能包含劇透或敏感內容"
@ -2537,7 +2529,7 @@ msgstr "日曆"
msgid "annual summary"
msgstr "年度小結"
#: journal/templates/profile.html:131 mastodon/api.py:678
#: journal/templates/profile.html:131 journal/views/collection.py:165
msgid "collection"
msgstr "收藏單"
@ -2655,16 +2647,25 @@ msgid_plural "%(count)d items"
msgstr[0] "%(count)d 個條目"
msgstr[1] "%(count)d 個條目"
#: journal/views/collection.py:38
#: journal/views/collection.py:36
#, python-brace-format
msgid "Collection by {0}"
msgstr "{0} 的收藏單"
#: journal/views/collection.py:190
#: journal/views/collection.py:170
msgid "shared my collection"
msgstr "分享我的收藏單"
#: journal/views/collection.py:173
#, python-brace-format
msgid "shared {username}'s collection"
msgstr "分享 {username} 的收藏單"
#: journal/views/collection.py:224
msgid "Unable to find the item, please use item url from this site."
msgstr "找不到條目,請使用本站條目網址。"
#: journal/views/collection.py:303 journal/views/collection.py:324
#: journal/views/collection.py:337 journal/views/collection.py:358
#: journal/views/review.py:124
msgid "Login required"
msgstr "登錄後訪問"
@ -2710,7 +2711,7 @@ msgstr "進度類型(選填)"
msgid "Invalid form data"
msgstr "無效表單信息。"
#: journal/views/post.py:41
#: journal/views/post.py:40
msgid "Post not found"
msgstr "帖文未找到"
@ -2748,55 +2749,120 @@ msgstr "重複標籤"
msgid "Tag updated."
msgstr "標籤已更新"
#: journal/views/wrapped.py:142
#: journal/views/wrapped.py:137
msgid "Summary posted to timeline."
msgstr "總結已發佈到時間軸"
#: mastodon/api.py:683
msgid "shared my collection"
msgstr "分享我的收藏單"
#: mastodon/models/common.py:12 users/templates/users/login.html:57
msgid "Email"
msgstr "電子郵件"
#: mastodon/api.py:686
#: mastodon/models/common.py:13
msgid "Mastodon"
msgstr "Mastodon"
#: mastodon/models/common.py:14 users/templates/users/login.html:75
msgid "Threads"
msgstr "Threads"
#: mastodon/models/common.py:15 users/templates/users/login.html:80
msgid "Bluesky"
msgstr "Bluesky"
#: mastodon/models/email.py:51
msgid ""
"\n"
"\n"
"If you did not mean to register or login, please ignore this email. If you are concerned with your account security, please change the email linked with your account, or contact us."
msgstr ""
"\n"
"\n"
"如果你沒有打算用此電子郵件地址註冊或登錄本站,請忽略此郵件;如果你確信賬號存在安全風險,請更改註冊郵件地址或與我們聯繫。"
#: mastodon/models/email.py:58
#, python-brace-format
msgid "shared {username}'s collection"
msgstr "分享 {username} 的收藏單"
msgid ""
"Use this code to verify your email address {email}\n"
"\n"
"{code}"
msgstr ""
"你好,\n"
"請用以下代碼驗證你的電子郵件地址 {email}\n"
"\n"
"{code}"
#: mastodon/models.py:7
#: mastodon/models/email.py:62
#, python-brace-format
msgid ""
"Use this code to login as {email}\n"
"\n"
"{code}"
msgstr ""
"你好,\n"
"請輸入如下驗證碼登錄{email}賬號:\n"
"\n"
"{code}\n"
#: mastodon/models/email.py:68
#, python-brace-format
msgid ""
"There is no account registered with this email address yet: {email}\n"
"\n"
"If you already have an account with us, just login and add this email to you account.\n"
"\n"
"If you prefer to register a new account with this email, please use this verification code: {code}"
msgstr ""
"你好,\n"
"本站沒有與{email}關聯的賬號。你希望註冊一個新賬號嗎?\n"
"\n"
"如果你已註冊過本站或某個聯邦宇宙(長毛象)實例,不必重新註冊,只要用聯邦宇宙身份登錄本站,再關聯這個電子郵件地址,即可通過郵件登錄。\n"
"\n"
"如果你確認要使用電子郵件新註冊賬號,請輸入如下驗證碼: {code}"
#: mastodon/models/mastodon.py:544
msgid "site domain name"
msgstr "實例域名"
msgstr "站點域名"
#: mastodon/models.py:8
#: mastodon/models/mastodon.py:545
msgid "domain for api call"
msgstr "實例API域名"
msgstr "站點API域名"
#: mastodon/models.py:9
#: mastodon/models/mastodon.py:546
msgid "type and verion"
msgstr "實例版本"
msgstr "站點類型和版本"
#: mastodon/models.py:10
#: mastodon/models/mastodon.py:547
msgid "in-site app id"
msgstr "實例應用id"
#: mastodon/models.py:11
#: mastodon/models/mastodon.py:548
msgid "client id"
msgstr "實例應用Client ID"
#: mastodon/models.py:12
#: mastodon/models/mastodon.py:549
msgid "client secret"
msgstr "實例應用Client Secret"
#: mastodon/models.py:13
#: mastodon/models/mastodon.py:550
msgid "vapid key"
msgstr "實例應用VAPID Key"
#: mastodon/models.py:15
#: mastodon/models/mastodon.py:552
msgid "0: custom emoji; 1: unicode moon; 2: text"
msgstr "實例表情模式"
#: mastodon/models.py:18
#: mastodon/models/mastodon.py:555
msgid "max toot len"
msgstr "帖文長度限制"
#: mastodon/models/mastodon.py:617
msgid "Boost"
msgstr "轉播"
#: mastodon/models/mastodon.py:618
msgid "New Post"
msgstr "新帖文"
#: social/templates/activity/comment_child_item.html:12
#: social/templates/feed_events.html:40
msgid "play"
@ -3053,171 +3119,97 @@ msgstr "頭像"
msgid "Header picture"
msgstr "背景圖片"
#: users/account.py:84
#: users/account.py:72
msgid "Invalid email address"
msgstr "無效的電子郵件地址"
#: users/account.py:102
#: users/account.py:79
msgid "Verification"
msgstr "驗證"
#: users/account.py:119
#: users/account.py:81 users/templates/users/verify.html:19
msgid "Verification email is being sent, please check your inbox."
msgstr "驗證郵件已發送,請查閱收件箱。"
#: users/account.py:96
msgid "Missing instance domain"
msgstr "未指定實例域名"
#: users/account.py:140
#: users/account.py:111
msgid "Error connecting to instance"
msgstr "無法連接實例"
#: users/account.py:155 users/account.py:165 users/account.py:178
#: users/account.py:201 users/account.py:226
#: users/account.py:126 users/account.py:136 users/account.py:149
#: users/account.py:164 users/account.py:175 users/account.py:198
#: users/account.py:313 users/account.py:340
msgid "Authentication failed"
msgstr "認證失敗"
#: users/account.py:156
#: users/account.py:127
msgid "Invalid response from Fediverse instance."
msgstr "聯邦實例返回信息無效"
#: users/account.py:166
#: users/account.py:137
msgid "Invalid cookie data."
msgstr "無效cookie信息。"
#: users/account.py:172
#: users/account.py:143
msgid "Invalid instance domain"
msgstr "實例域名無效"
#: users/account.py:179
#: users/account.py:150
msgid "Invalid token from Fediverse instance."
msgstr "聯邦實例返回了無效的認證令牌。"
#: users/account.py:202 users/account.py:554
#: users/account.py:165 users/account.py:459
msgid "Invalid account data from Fediverse instance."
msgstr "聯邦實例返回了無效的賬號數據。"
#: users/account.py:227
#: users/account.py:176 users/account.py:341
msgid "Invalid user."
msgstr "無效用戶。"
#: users/account.py:199
msgid "Registration is for invitation only"
msgstr "本站僅限邀請註冊"
#: users/account.py:286
#: users/account.py:265
msgid "This username is already in use."
msgstr "用戶名已被使用"
#: users/account.py:297
#: users/account.py:276
msgid "This email address is already in use."
msgstr "此電子郵件地址已被使用"
#: users/account.py:305
msgid ""
"\n"
"\n"
"If you did not mean to register or login, please ignore this email. If you are concerned with your account security, please change the email linked with your account, or contact us."
msgstr ""
"\n"
"\n"
"如果你沒有打算用此電子郵件地址註冊或登錄本站,請忽略此郵件;如果你確信賬號存在安全風險,請更改註冊郵件地址或與我們聯繫。"
#: users/account.py:311
#, python-brace-format
msgid ""
"Click this link to verify your email address {email}\n"
"{url}"
msgstr ""
"你好,\n"
"請點擊以下鏈接驗證你的電子郵件地址 {email}\n"
"{url}"
#: users/account.py:319
#, python-brace-format
msgid ""
"Use this code to confirm login as {email}\n"
"\n"
"{code}\n"
"\n"
"Or click this link to login\n"
"{url}"
msgstr ""
"你好,\n"
"請在登錄界面輸入如下驗證碼:\n"
"\n"
"{code}\n"
"\n"
"或點擊以下鏈接登錄{email}賬號\n"
"{url}"
#: users/account.py:326
#, python-brace-format
msgid ""
"There is no account registered with this email address yet.{email}\n"
"\n"
"If you already have an account with a Fediverse identity, just login and add this email to you account.\n"
"\n"
msgstr ""
"你好,\n"
"本站沒有與{email}關聯的賬號。你希望註冊一個新賬號嗎?\n"
"\n"
"如果你已註冊過本站或某個聯邦宇宙(長毛象)實例,不必重新註冊,只要用聯邦宇宙身份登錄本站,再關聯這個電子郵件地址,即可通過郵件登錄。\n"
"\n"
"如果你還沒有聯邦宇宙身份,可以訪問這裏選擇實例並創建一個: https://joinmastodon.org/zh/servers\n"
#: users/account.py:330
#, python-brace-format
msgid ""
"\n"
"If you prefer to register a new account, please use this code: {code}\n"
"Or click this link:\n"
"{url}"
msgstr ""
"\n"
"如果你確定使用電子郵件註冊一個新賬號,可以輸入這個驗證碼\n"
"{code}\n"
"或者點擊以下鏈接\n"
"{url}"
#: users/account.py:357 users/account.py:366
#: users/account.py:290 users/account.py:299
msgid "Invalid verification code"
msgstr "驗證碼無效或已過期"
#: users/account.py:385
msgid "Invalid verification link"
msgstr "驗證鏈接無效或已過期"
#: users/account.py:402 users/account.py:408
msgid "Email mismatch"
msgstr "電子郵件地址不匹配"
#: users/account.py:412 users/account.py:464
msgid "Email in use"
#: users/account.py:314
msgid "Email already in use"
msgstr "電子郵件地址已被註冊"
#: users/account.py:417
msgid "Unable to verify"
msgstr "無法完成驗證"
#: users/account.py:382
msgid "Valid username required"
msgstr "請輸入有效的用戶名"
#: users/account.py:449
#: users/account.py:386
msgid "Username in use"
msgstr "用戶名已被使用"
#: users/account.py:490
msgid "Username all set."
msgstr "用戶名已設置。"
#: users/account.py:493
msgid "Email removed from account."
msgstr "電子郵件地址已取消關聯。"
#: users/account.py:514
#: users/account.py:418
msgid "Unable to update login information: identical identity."
msgstr "無法更新登錄信息:該身份與當前賬號相同。"
#: users/account.py:524
#: users/account.py:424
msgid "Unable to update login information: identity in use."
msgstr "無法更新登錄信息:該身份已被其它賬號使用。"
#: users/account.py:550
#: users/account.py:455
msgid "Login information updated."
msgstr "登錄信息已更新"
#: users/account.py:604
#: users/account.py:508
msgid "Account mismatch."
msgstr "賬號信息不匹配。"
@ -3273,27 +3265,27 @@ msgstr "完成"
msgid "Failed"
msgstr "失敗"
#: users/models/user.py:48
#: users/models/user.py:50
msgid "Enter a valid username. This value may contain only unaccented lowercase a-z and uppercase A-Z letters, numbers, and _ characters."
msgstr "輸入用戶名限英文字母數字下劃線最多30個字符。"
#: users/models/user.py:81
#: users/models/user.py:85
msgid "username"
msgstr "用戶名"
#: users/models/user.py:85
#: users/models/user.py:89
msgid "Required. 50 characters or fewer. Letters, digits and _ only."
msgstr "必填限英文字母數字下劃線最多30個字符。"
#: users/models/user.py:88
#: users/models/user.py:92
msgid "A user with that username already exists."
msgstr "使用該用戶名的用戶已存在。"
#: users/models/user.py:92
#: users/models/user.py:105
msgid "email address"
msgstr "電子郵件地址"
#: users/models/user.py:98
#: users/models/user.py:111
msgid "email address pending verification"
msgstr "待驗證的電子郵件地址"
@ -3309,128 +3301,132 @@ msgstr "用戶名、電子郵件與社交身份"
msgid "Username"
msgstr "用戶名"
#: users/templates/users/account.html:27 users/templates/users/register.html:31
#: users/templates/users/account.html:27 users/templates/users/register.html:23
msgid "2-30 alphabets, numbers or underscore, can't be changed once saved"
msgstr "2-30個字符限英文字母數字下劃線保存後不可更改"
#: users/templates/users/account.html:30 users/templates/users/register.html:35
msgid "email address (optional if you log in via other Fediverse site, but recommended)"
msgstr "以及作爲備用登錄方式的電子郵件地址(推薦)"
#: users/templates/users/account.html:30 users/templates/users/register.html:27
msgid "Email address"
msgstr "電子郵件地址"
#: users/templates/users/account.html:38 users/templates/users/register.html:43
#: users/templates/users/account.html:39 users/templates/users/register.html:36
#, python-format
msgid "Please click the confirmation link in the email sent to %(pending_email)s; if you haven't received it for more than a few minutes, please input and save again."
msgstr "當前待確認的電子郵件地址爲%(pending_email)s請查收郵件並點擊確認鏈接如長時間未收到可重新輸入並保存。"
#: users/templates/users/account.html:50
#: users/templates/users/account.html:41 users/templates/users/register.html:39
msgid "Email is recommended as a backup login method, if you log in via a Fediverse instance"
msgstr "推薦輸入電子郵件地址作爲備用登錄方式。"
#: users/templates/users/account.html:54
msgid "Associated identities"
msgstr "已關聯社交身份"
#: users/templates/users/account.html:58
#: users/templates/users/account.html:62
msgid "If you have not yet registered with any Federated instance, you may <a href=\"https://joinmastodon.org/zh/servers\" target=\"_blank\">choose an instance</a> and register."
msgstr "如果你還沒有在任何<em data-tooltip=\"聯邦宇宙(Fediverse 有時也被稱爲長毛象)是一種分佈式社交網絡\">聯邦宇宙</em>實例註冊過,可先<a href=\"https://joinmastodon.org/zh/servers\" target=\"_blank\">選擇實例並註冊</a>。"
#: users/templates/users/account.html:63
#: users/templates/users/account.html:67
msgid "To associate with another federated identity, please enter the domain name of the instance where the new identity is located."
msgstr "如需關聯到另一個聯邦宇宙社交身份,請輸入新身份所在的實例域名"
#: users/templates/users/account.html:65
#: users/templates/users/account.html:69
msgid "If you have registered with a Federated instance, please enter the instance domain name."
msgstr "如果你已經註冊過聯邦宇宙實例,請輸入實例域名"
#: users/templates/users/account.html:74
#: users/templates/users/account.html:78
msgid "Go to target instance and authorize with the identity"
msgstr "登錄實例並關聯"
#: users/templates/users/account.html:79
#: users/templates/users/account.html:83
msgid "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."
msgstr "替換關聯後可使用新的聯邦宇宙身份來登錄本站和控制數據可見性,已有的標記評論收藏單等數據不受影響。"
#: users/templates/users/account.html:81
#: users/templates/users/account.html:85
msgid "Once associated with Fediverse identity, you can discover more users and use the full features of this site."
msgstr "關聯聯邦宇宙身份後可發現更多用戶,並使用本站完整功能。"
#: users/templates/users/account.html:91
#: users/templates/users/account.html:95
msgid "Display name, avatar and other information"
msgstr "暱稱、頭像與其它個人信息"
#: users/templates/users/account.html:94
#: users/templates/users/account.html:98
msgid "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?"
msgstr "在這裏更新個人資料會停止從關聯實例自動同步暱稱等個人信息,確定繼續嗎?"
#: users/templates/users/account.html:108
#: users/templates/users/account.html:112
msgid "Users you are following"
msgstr "正在關注的用戶"
#: users/templates/users/account.html:114
#: users/templates/users/account.html:118
msgid "Users who follow you"
msgstr "關注了你的用戶"
#: users/templates/users/account.html:120
#: users/templates/users/account.html:124
msgid "Users who request to follow you"
msgstr "請求關注你的用戶"
#: users/templates/users/account.html:126
#: users/templates/users/account.html:130
msgid "Users you are muting"
msgstr "已隱藏的用戶"
#: users/templates/users/account.html:132
#: users/templates/users/account.html:136
msgid "Users you are blocking"
msgstr "已屏蔽的用戶"
#: users/templates/users/account.html:138
#: users/templates/users/account.html:142
msgid "Sync and import social account"
msgstr "同步聯邦宇宙信息和社交數據"
#: users/templates/users/account.html:148
#: users/templates/users/account.html:152
msgid "Sync display name, bio and avatar"
msgstr "自動同步用戶暱稱等基本信息"
#: users/templates/users/account.html:156
#: users/templates/users/account.html:160
msgid "Sync follow, mute and block"
msgstr "自動導入新增的關注、屏蔽和隱藏列表"
#: users/templates/users/account.html:160
#: users/templates/users/account.html:164
msgid "Save sync settings"
msgstr "保存同步設置"
#: users/templates/users/account.html:163
#: users/templates/users/account.html:167
msgid "New follow, mute and blocks in the associated identity may be automatically imported; removal has to be done manually."
msgstr "本站會按照以上設置每天自動導入你在聯邦宇宙實例中新增的關注、屏蔽和隱藏列表如果你在聯邦宇宙實例中關注的用戶加入了NeoDB你會自動關注她如果你在聯邦宇宙實例中取消了關注、屏蔽或隱藏本站不會自動取消但你可以手動移除。"
#: users/templates/users/account.html:170
#: users/templates/users/account.html:174
msgid "Click button below to start sync now."
msgstr "如果希望立即開始同步,可以點擊下方按鈕。"
#: users/templates/users/account.html:172
#: users/templates/users/account.html:176
msgid "Sync now"
msgstr "立即同步"
#: users/templates/users/account.html:176
#: users/templates/users/account.html:180
msgid "Last updated"
msgstr "最近更新"
#: users/templates/users/account.html:184
#: users/templates/users/account.html:188
msgid "Delete Account"
msgstr "刪除賬號"
#: users/templates/users/account.html:187
#: users/templates/users/account.html:191
msgid "Once deleted, account data cannot be recovered. Sure to proceed?"
msgstr "賬號數據一旦刪除後將無法恢復,確定繼續嗎?"
#: users/templates/users/account.html:190
#: users/templates/users/account.html:194
msgid "Enter full <code>username@instance.social</code> or <code>email@domain.com</code> to confirm deletion."
msgstr "輸入完整的登錄用 <code>用戶名@實例名</code> 或 <code>電子郵件地址</code> 以確認刪除"
#: users/templates/users/account.html:200
#: users/templates/users/account.html:204
msgid "Once deleted, account data cannot be recovered."
msgstr "賬號數據一旦刪除後將無法恢復"
#: users/templates/users/account.html:202
#: users/templates/users/account.html:206
msgid "Importing in progress, can't delete now."
msgstr "暫時無法刪除,因爲有導入任務正在進行"
#: users/templates/users/account.html:205
#: users/templates/users/account.html:209
msgid "Permanently Delete"
msgstr "永久刪除"
@ -3557,6 +3553,7 @@ msgid "Searching the fediverse"
msgstr "正在搜索聯邦宇宙"
#: users/templates/users/login.html:16 users/templates/users/register.html:8
#: users/templates/users/welcome.html:8
msgid "Register"
msgstr "註冊"
@ -3569,85 +3566,73 @@ msgstr "登錄"
msgid "back to your home page."
msgstr "返回首頁"
#: users/templates/users/login.html:56
msgid "Email"
msgstr "電子郵件"
#: users/templates/users/login.html:61
#: users/templates/users/login.html:63
msgid "Fediverse (Mastodon)"
msgstr "聯邦宇宙(有時也被稱爲長毛象)"
#: users/templates/users/login.html:71
msgid "Threads"
msgstr "Threads"
#: users/templates/users/login.html:74
msgid "Bluesky"
msgstr "Bluesky"
#: users/templates/users/login.html:89
#: users/templates/users/login.html:97
msgid "Enter your email address"
msgstr "輸入電子郵件地址"
#: users/templates/users/login.html:91
#: users/templates/users/login.html:99
msgid "Verify Email"
msgstr "驗證電子郵件"
#: users/templates/users/login.html:106
msgid "domain of your instance, e.g. mastodon.social"
#: users/templates/users/login.html:114
msgid "Domain of your instance, e.g. mastodon.social"
msgstr "實例域名(不含@和@之前的部分)如mastodon.social"
#: users/templates/users/login.html:112
#: users/templates/users/login.html:120
msgid "Please enter domain of your instance; e.g. if your id is <i>@neodb@mastodon.social</i>, only enter <i>mastodon.social</i>."
msgstr "請輸入你的實例域名(不含@和@之前的部分);如果你的聯邦賬號是<i>@neodb@mastodon.social</i>只需要在此輸入<i>mastodon.social</i>。"
msgstr "請輸入你的實例域名(不含@和@之前的部分);如果你的聯邦賬號是<i>@neodb@mastodon.social</i>只需要在此輸入<i>mastodon.social</i>。"
#: users/templates/users/login.html:115 users/templates/users/login.html:145
#: users/templates/users/login.html:123 users/templates/users/login.html:154
msgid "Authorize via Fediverse instance"
msgstr "去聯邦實例授權註冊或登錄"
#: users/templates/users/login.html:117
#: users/templates/users/login.html:125
msgid "If you don't have a <a href=\"https://joinmastodon.org/servers\" target=\"_blank\">Fediverse (Mastodon) account</a> yet, you may register or login with Email first, and link it with Fediverse (Mastodon) later in account settings."
msgstr "如果你還沒有或不便註冊<a href=\"https://joinmastodon.org/servers\" target=\"_blank\">聯邦實例</a>賬號,也可先通過電子郵件或其它平臺註冊登錄,未來再作關聯。"
#: users/templates/users/login.html:127
#: users/templates/users/login.html:135
msgid "Authorize via Threads"
msgstr "去Threads授權註冊或登錄"
#: users/templates/users/login.html:128
#: users/templates/users/login.html:136
msgid "If you have already account here registered via a different way, you may login through there and link with your Threads account in account settings."
msgstr "如果你已通過其它方式註冊過本站帳號請用該方式登錄後再關聯Threads。"
#: users/templates/users/login.html:137
#: users/templates/users/login.html:145
msgid "Authorize via Bluesky"
msgstr "去Bluesky授權註冊或登錄"
#: users/templates/users/login.html:138
#: users/templates/users/login.html:146
msgid "If you have already account here registered via a different way, you may login through there and link with your Bluesky account in account settings."
msgstr "如果你已通過其它方式註冊過本站帳號請用該方式登錄後再關聯Bluesky。"
#: users/templates/users/login.html:151
#: users/templates/users/login.html:161
msgid "Valid invitation code, please login or register."
msgstr "邀請鏈接有效,可註冊新用戶"
#: users/templates/users/login.html:153
#: users/templates/users/login.html:163
msgid "Please use invitation link to register a new account; existing user may login."
msgstr "本站目前爲邀請註冊,已有賬戶可直接登入,新用戶請使用有效邀請鏈接註冊"
#: users/templates/users/login.html:155
#: users/templates/users/login.html:165
msgid "Invitation code invalid or expired."
msgstr "邀請鏈接無效,已有賬戶可直接登入,新用戶請使用有效邀請鏈接註冊"
#: users/templates/users/login.html:163
#: users/templates/users/login.html:173
msgid "Loading timed out, please check your network (VPN) settings."
msgstr "部分模塊加載超時,請檢查網絡(翻牆)設置。"
#: users/templates/users/login.html:169
msgid "Using this site implies consent of our <a href=\"/pages/rules/\">rules</a> and <a href=\"/pages/terms/\">terms</a>, and use of cookies to provide necessary functionality."
#: users/templates/users/login.html:179
msgid "Continue using this site implies consent to our <a href=\"/pages/rules/\">rules</a> and <a href=\"/pages/terms/\">terms</a>, including using cookies to provide necessary functionality."
msgstr "繼續訪問或註冊視爲同意<a href=\"/pages/rules/\">站規</a>與<a href=\"/pages/terms/\">協議</a>及使用cookie提供必要功能"
#: users/templates/users/login.html:175
msgid "select or input domain name of your instance (excl. @)"
msgstr "輸入或選擇實例域名(不含@和@之前的部分)"
#: users/templates/users/login.html:185
msgid "Domain of your instance (excl. @)"
msgstr "實例域名(不含@和@之前的部分)"
#: users/templates/users/preferences.html:26
msgid "Default view once logged in"
@ -3857,34 +3842,16 @@ msgstr "點擊可屏蔽"
msgid "sure to block?"
msgstr "確定屏蔽該用戶嗎?"
#: users/templates/users/register.html:18
msgid "Welcome"
msgstr "歡迎"
#: users/templates/users/register.html:20
#, python-format
msgid ""
"\n"
" %(site_name)s is flourishing because of collaborations and contributions from users like you. Please read our <a href=\"/pages/terms\">term of service</a>, and feel free to <a href=\"%(support_link)s\">contact us</a> if you have any question or feedback.\n"
" "
msgstr ""
"\n"
"%(site_name)s還在不斷完善中。 豐富的內容需要大家共同創造,試圖添加垃圾數據(如添加信息混亂或缺失的書籍、以推廣爲主要目的的評論)將會受到嚴肅處理。 本站爲非盈利站點cookie和其它數據保管使用原則請參閱<a href=\"/pages/terms\">站內公告</a>。 本站提供API和導出功能請妥善備份您的數據使用過程中遇到的問題或者錯誤歡迎向<a href=\"%(support_link)s\">維護者</a>提出。感謝理解和支持!"
#: users/templates/users/register.html:30
#: users/templates/users/register.html:22
#, python-format
msgid "Your username on %(site_name)s"
msgstr "你在%(site_name)s使用的用戶名"
#: users/templates/users/register.html:49
#: users/templates/users/register.html:50
msgid "Confirm and save"
msgstr "確認並保存"
#: users/templates/users/register.html:50
msgid "Once saved, click the confirmation link in the email you receive"
msgstr "設置後請查收郵件並點擊其中的確認鏈接"
#: users/templates/users/register.html:54
#: users/templates/users/register.html:54 users/templates/users/welcome.html:24
msgid "Cut the sh*t and get me in!"
msgstr ""
@ -3896,6 +3863,10 @@ msgstr "導出"
msgid "You may download the list here."
msgstr "此處可導出你在本站的關係列表。"
#: users/templates/users/verify.html:21
msgid "Please enter the verification code you received."
msgstr "請輸入收到的驗證碼。"
#: users/templates/users/verify_email.html:8
#: users/templates/users/verify_email.html:17
msgid "Verify Your Email"
@ -3908,3 +3879,17 @@ msgstr "驗證成功"
#: users/templates/users/verify_email.html:27
msgid "login again"
msgstr "重新登錄"
#: users/templates/users/welcome.html:17
msgid "Welcome"
msgstr "歡迎"
#: users/templates/users/welcome.html:19
#, python-format
msgid ""
"\n"
" %(site_name)s is flourishing because of collaborations and contributions from users like you. Please read our <a href=\"/pages/terms\">term of service</a>, and feel free to <a href=\"%(support_link)s\">contact us</a> if you have any question or feedback.\n"
" "
msgstr ""
"\n"
"%(site_name)s還在不斷完善中。 豐富的內容需要大家共同創造,試圖添加垃圾數據(如添加信息混亂或缺失的書籍、以推廣爲主要目的的評論)將會受到嚴肅處理。 本站爲非盈利站點cookie和其它數據保管使用原則請參閱<a href=\"/pages/terms\">站內公告</a>。 本站提供API和導出功能請妥善備份您的數據使用過程中遇到的問題或者錯誤歡迎向<a href=\"%(support_link)s\">維護者</a>提出。感謝理解和支持!"

View file

@ -1,700 +0,0 @@
import functools
import random
import re
import string
import time
from urllib.parse import quote
import django_rq
import requests
from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext as _
from loguru import logger
from mastodon.utils import rating_to_emoji
from .models import MastodonApplication
# See https://docs.joinmastodon.org/methods/accounts/
# returns user info
# retruns the same info as verify account credentials
# GET
API_GET_ACCOUNT = "/api/v1/accounts/:id"
# returns user info if valid, 401 if invalid
# GET
API_VERIFY_ACCOUNT = "/api/v1/accounts/verify_credentials"
# obtain token
# GET
API_OBTAIN_TOKEN = "/oauth/token"
# obatin auth code
# GET
API_OAUTH_AUTHORIZE = "/oauth/authorize"
# revoke token
# POST
API_REVOKE_TOKEN = "/oauth/revoke"
# relationships
# GET
API_GET_RELATIONSHIPS = "/api/v1/accounts/relationships"
# toot
# POST
API_PUBLISH_TOOT = "/api/v1/statuses"
# create new app
# POST
API_CREATE_APP = "/api/v1/apps"
# search
# GET
API_SEARCH = "/api/v2/search"
USER_AGENT = settings.NEODB_USER_AGENT
get = functools.partial(requests.get, timeout=settings.MASTODON_TIMEOUT)
put = functools.partial(requests.put, timeout=settings.MASTODON_TIMEOUT)
post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT)
def get_api_domain(domain):
app = MastodonApplication.objects.filter(domain_name=domain).first()
return app.api_domain if app and app.api_domain else domain
# low level api below
def boost_toot(site, token, toot_url):
domain = get_api_domain(site)
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {token}",
}
url = (
"https://"
+ domain
+ API_SEARCH
+ "?type=statuses&resolve=true&q="
+ quote(toot_url)
)
try:
response = get(url, headers=headers)
if response.status_code != 200:
logger.warning(
f"Error search {toot_url} on {domain} {response.status_code}"
)
return None
j = response.json()
if "statuses" in j and len(j["statuses"]) > 0:
s = j["statuses"][0]
url_id = toot_url.split("/posts/")[-1]
url_id2 = s["uri"].split("/posts/")[-1]
if s["uri"] != toot_url and s["url"] != toot_url and url_id != url_id2:
logger.warning(
f"Error status url mismatch {s['uri']} or {s['uri']} != {toot_url}"
)
return None
if s["reblogged"]:
logger.warning(f"Already boosted {toot_url}")
# TODO unboost and boost again?
return None
url = (
"https://"
+ domain
+ API_PUBLISH_TOOT
+ "/"
+ j["statuses"][0]["id"]
+ "/reblog"
)
response = post(url, headers=headers)
if response.status_code != 200:
logger.warning(
f"Error search {toot_url} on {domain} {response.status_code}"
)
return None
return response.json()
except Exception:
logger.warning(f"Error search {toot_url} on {domain}")
return None
def boost_toot_later(user, post_url):
if user and user.mastodon_token and user.mastodon_site and post_url:
django_rq.get_queue("fetch").enqueue(
boost_toot, user.mastodon_site, user.mastodon_token, post_url
)
def post_toot_later(
user,
content,
visibility,
local_only=False,
update_id=None,
spoiler_text=None,
img=None,
img_name=None,
img_type=None,
):
if user and user.mastodon_token and user.mastodon_site and content:
django_rq.get_queue("fetch").enqueue(
post_toot,
user.mastodon_site,
content,
visibility,
user.mastodon_token,
local_only,
update_id,
spoiler_text,
img,
img_name,
img_type,
)
def post_toot(
site,
content,
visibility,
token,
local_only=False,
update_id=None,
spoiler_text=None,
img=None,
img_name=None,
img_type=None,
):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {token}",
"Idempotency-Key": random_string_generator(16),
}
media_id = None
if img and img_name and img_type:
try:
media_id = (
requests.post(
"https://" + get_api_domain(site) + "/api/v1/media",
headers=headers,
data={},
files={"file": (img_name, img, img_type)},
)
.json()
.get("id")
)
ready = False
while ready is False:
time.sleep(3)
j = requests.get(
"https://" + get_api_domain(site) + "/api/v1/media/" + media_id,
headers=headers,
).json()
ready = j.get("url") is not None
except Exception as e:
logger.warning(f"Error uploading image {e}")
headers["Idempotency-Key"] = random_string_generator(16)
response = None
url = "https://" + get_api_domain(site) + API_PUBLISH_TOOT
payload = {
"status": content,
"visibility": visibility,
}
if media_id:
payload["media_ids[]"] = [media_id]
if spoiler_text:
payload["spoiler_text"] = spoiler_text
if local_only:
payload["local_only"] = True
try:
if update_id:
response = put(url + "/" + update_id, headers=headers, data=payload)
if not update_id or (response is not None and response.status_code != 200):
headers["Idempotency-Key"] = random_string_generator(16)
response = post(url, headers=headers, data=payload)
if response is not None and response.status_code == 201:
response.status_code = 200
if response is not None and response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
except Exception as e:
logger.warning(f"Error posting {e}")
response = None
return response
def delete_toot(user, toot_url):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {user.mastodon_token}",
"Idempotency-Key": random_string_generator(16),
}
toot_id = get_status_id_by_url(toot_url)
url = (
"https://"
+ get_api_domain(user.mastodon_site)
+ API_PUBLISH_TOOT
+ "/"
+ toot_id
)
try:
response = requests.delete(url, headers=headers)
if response.status_code != 200:
logger.warning(f"Error DELETE {url} {response.status_code}")
except Exception as e:
logger.warning(f"Error deleting {e}")
def delete_toot_later(user, toot_url):
if user and user.mastodon_token and user.mastodon_site and toot_url:
django_rq.get_queue("fetch").enqueue(delete_toot, user, toot_url)
def post_toot2(
user,
content,
visibility,
update_toot_url: str | None = None,
reply_to_toot_url: str | None = None,
sensitive: bool = False,
spoiler_text: str | None = None,
attachments: list = [],
):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {user.mastodon_token}",
"Idempotency-Key": random_string_generator(16),
}
base_url = "https://" + get_api_domain(user.mastodon_site)
response = None
url = base_url + API_PUBLISH_TOOT
payload = {
"status": content,
"visibility": get_toot_visibility(visibility, user),
}
update_id = get_status_id_by_url(update_toot_url)
reply_to_id = get_status_id_by_url(reply_to_toot_url)
if reply_to_id:
payload["in_reply_to_id"] = reply_to_id
if spoiler_text:
payload["spoiler_text"] = spoiler_text
if sensitive:
payload["sensitive"] = True
media_ids = []
for atta in attachments:
try:
media_id = (
requests.post(
base_url + "/api/v1/media",
headers=headers,
data={},
files={"file": atta},
)
.json()
.get("id")
)
media_ids.append(media_id)
except Exception as e:
logger.warning(f"Error uploading image {e}")
headers["Idempotency-Key"] = random_string_generator(16)
if media_ids:
payload["media_ids[]"] = media_ids
try:
if update_id:
response = put(url + "/" + update_id, headers=headers, data=payload)
if not update_id or (response is not None and response.status_code != 200):
headers["Idempotency-Key"] = random_string_generator(16)
response = post(url, headers=headers, data=payload)
if response is not None and response.status_code != 200:
headers["Idempotency-Key"] = random_string_generator(16)
payload["in_reply_to_id"] = None
response = post(url, headers=headers, data=payload)
if response is not None and response.status_code == 201:
response.status_code = 200
if response is not None and response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
except Exception as e:
logger.warning(f"Error posting {e}")
response = None
return response
def _get_redirect_uris(allow_multiple=True) -> str:
u = settings.SITE_INFO["site_url"] + "/account/login/oauth"
if not allow_multiple:
return u
u2s = [f"https://{d}/account/login/oauth" for d in settings.ALTERNATIVE_DOMAINS]
return "\n".join([u] + u2s)
def create_app(domain_name, allow_multiple_redir):
url = "https://" + domain_name + API_CREATE_APP
payload = {
"client_name": settings.SITE_INFO["site_name"],
"scopes": settings.MASTODON_CLIENT_SCOPE,
"redirect_uris": _get_redirect_uris(allow_multiple_redir),
"website": settings.SITE_INFO["site_url"],
}
response = post(url, data=payload, headers={"User-Agent": USER_AGENT})
return response
def webfinger(site, username) -> dict | None:
url = f"https://{site}/.well-known/webfinger?resource=acct:{username}@{site}"
try:
response = get(url, headers={"User-Agent": USER_AGENT})
if response.status_code != 200:
logger.warning(f"Error webfinger {username}@{site} {response.status_code}")
return None
j = response.json()
return j
except Exception:
logger.warning(f"Error webfinger {username}@{site}")
return None
# utils below
def random_string_generator(n):
s = string.ascii_letters + string.punctuation + string.digits
return "".join(random.choice(s) for i in range(n))
def verify_account(site, token):
url = "https://" + get_api_domain(site) + API_VERIFY_ACCOUNT
try:
response = get(
url, headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
)
return response.status_code, (
response.json() if response.status_code == 200 else None
)
except Exception:
return -1, None
def get_related_acct_list(site, token, api):
url = "https://" + get_api_domain(site) + api
results = []
while url:
try:
response = get(
url,
headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"},
)
url = None
if response.status_code == 200:
r: list[dict[str, str]] = response.json()
results.extend(
map(
lambda u: (
( # type: ignore
u["acct"]
if u["acct"].find("@") != -1
else u["acct"] + "@" + site
)
if "acct" in u
else u
),
r,
)
)
if "Link" in response.headers:
for ls in response.headers["Link"].split(","):
li = ls.strip().split(";")
if li[1].strip() == 'rel="next"':
url = li[0].strip().replace(">", "").replace("<", "")
except Exception as e:
logger.warning(f"Error GET {url} : {e}")
url = None
return results
class TootVisibilityEnum:
PUBLIC = "public"
PRIVATE = "private"
DIRECT = "direct"
UNLISTED = "unlisted"
def detect_server_info(login_domain: str) -> tuple[str, str, str]:
url = f"https://{login_domain}/api/v1/instance"
try:
response = get(url, headers={"User-Agent": USER_AGENT})
except Exception as e:
logger.error(f"Error connecting {login_domain}", extra={"exception": e})
raise Exception(f"Error connecting to instance {login_domain}")
if response.status_code != 200:
logger.error(f"Error connecting {login_domain}", extra={"response": response})
raise Exception(
f"Instance {login_domain} returned error code {response.status_code}"
)
try:
j = response.json()
domain = j["uri"].lower().split("//")[-1].split("/")[0]
except Exception as e:
logger.error(f"Error connecting {login_domain}", extra={"exception": e})
raise Exception(f"Instance {login_domain} returned invalid data")
server_version = j["version"]
api_domain = domain
if domain != login_domain:
url = f"https://{domain}/api/v1/instance"
try:
response = get(url, headers={"User-Agent": USER_AGENT})
j = response.json()
except Exception:
api_domain = login_domain
logger.info(
f"detect_server_info: {login_domain} {domain} {api_domain} {server_version}"
)
return domain, api_domain, server_version
def get_or_create_fediverse_application(login_domain):
domain = login_domain
app = MastodonApplication.objects.filter(domain_name__iexact=domain).first()
if not app:
app = MastodonApplication.objects.filter(api_domain__iexact=domain).first()
if app:
return app
if not settings.MASTODON_ALLOW_ANY_SITE:
logger.warning(f"Disallowed to create app for {domain}")
raise ValueError("Unsupported instance")
if login_domain.lower() in settings.SITE_DOMAINS:
raise ValueError("Unsupported instance")
domain, api_domain, server_version = detect_server_info(login_domain)
if (
domain.lower() in settings.SITE_DOMAINS
or api_domain.lower() in settings.SITE_DOMAINS
):
raise ValueError("Unsupported instance")
if "neodb/" in server_version:
raise ValueError("Unsupported instance type")
if login_domain != domain:
app = MastodonApplication.objects.filter(domain_name__iexact=domain).first()
if app:
return app
allow_multiple_redir = True
if "; Pixelfed" in server_version or server_version.startswith("0."):
# Pixelfed and GoToSocial don't support multiple redirect uris
allow_multiple_redir = False
response = create_app(api_domain, allow_multiple_redir)
if response.status_code != 200:
logger.error(
f"Error creating app for {domain} on {api_domain}: {response.status_code}"
)
raise Exception("Error creating app, code: " + str(response.status_code))
try:
data = response.json()
except Exception:
logger.error(f"Error creating app for {domain}: unable to parse response")
raise Exception("Error creating app, invalid response")
app = MastodonApplication.objects.create(
domain_name=domain.lower(),
api_domain=api_domain.lower(),
server_version=server_version,
app_id=data["id"],
client_id=data["client_id"],
client_secret=data["client_secret"],
vapid_key=data.get("vapid_key", ""),
)
# create a client token to avoid vacuum by Mastodon 4.2+
try:
verify_client(app)
except Exception as e:
logger.error(f"Error creating client token for {domain}", extra={"error": e})
return app
def get_mastodon_login_url(app, login_domain, request):
url = request.build_absolute_uri(reverse("users:login_oauth"))
version = app.server_version or ""
scope = (
settings.MASTODON_LEGACY_CLIENT_SCOPE
if "Pixelfed" in version
else settings.MASTODON_CLIENT_SCOPE
)
return (
"https://"
+ login_domain
+ "/oauth/authorize?client_id="
+ app.client_id
+ "&scope="
+ quote(scope)
+ "&redirect_uri="
+ url
+ "&response_type=code"
)
def verify_client(mast_app):
payload = {
"client_id": mast_app.client_id,
"client_secret": mast_app.client_secret,
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
"scope": settings.MASTODON_CLIENT_SCOPE,
"grant_type": "client_credentials",
}
headers = {"User-Agent": USER_AGENT}
url = "https://" + (mast_app.api_domain or mast_app.domain_name) + API_OBTAIN_TOKEN
try:
response = post(
url, data=payload, headers=headers, timeout=settings.MASTODON_TIMEOUT
)
except Exception as e:
logger.warning(f"Error {url} {e}")
return False
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return False
data = response.json()
return data.get("access_token") is not None
def obtain_token(site, request, code):
"""Returns token if success else None."""
mast_app = MastodonApplication.objects.get(domain_name=site)
redirect_uri = request.build_absolute_uri(reverse("users:login_oauth"))
payload = {
"client_id": mast_app.client_id,
"client_secret": mast_app.client_secret,
"redirect_uri": redirect_uri,
"scope": settings.MASTODON_CLIENT_SCOPE,
"grant_type": "authorization_code",
"code": code,
}
headers = {"User-Agent": USER_AGENT}
auth = None
if mast_app.is_proxy:
url = "https://" + mast_app.proxy_to + API_OBTAIN_TOKEN
else:
url = (
"https://"
+ (mast_app.api_domain or mast_app.domain_name)
+ API_OBTAIN_TOKEN
)
try:
response = post(url, data=payload, headers=headers, auth=auth)
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return None, None
except Exception as e:
logger.warning(f"Error {url} {e}")
return None, None
data = response.json()
return data.get("access_token"), data.get("refresh_token", "")
def revoke_token(site, token):
mast_app = MastodonApplication.objects.get(domain_name=site)
payload = {
"client_id": mast_app.client_id,
"client_secret": mast_app.client_secret,
"token": token,
}
if mast_app.is_proxy:
url = "https://" + mast_app.proxy_to + API_REVOKE_TOKEN
else:
url = "https://" + get_api_domain(site) + API_REVOKE_TOKEN
post(url, data=payload, headers={"User-Agent": USER_AGENT})
def get_status_id_by_url(url):
if not url:
return None
r = re.match(
r".+/(\w+)$", url
) # might be re.match(r'.+/([^/]+)$', u) if Pleroma supports edit
return r[1] if r else None
def get_spoiler_text(text, item):
if text.find(">!") != -1:
spoiler_text = _(
"regarding {item_title}, may contain spoiler or triggering content"
).format(item_title=item.display_title)
return spoiler_text, text.replace(">!", "").replace("!<", "")
else:
return None, text
def get_toot_visibility(visibility, user):
if visibility == 2:
return TootVisibilityEnum.DIRECT
elif visibility == 1:
return TootVisibilityEnum.PRIVATE
elif user.preference.post_public_mode == 0:
return TootVisibilityEnum.PUBLIC
else:
return TootVisibilityEnum.UNLISTED
def share_mark(mark, post_as_new=False):
from catalog.common import ItemCategory
user = mark.owner.user
visibility = get_toot_visibility(mark.visibility, user)
site = MastodonApplication.objects.filter(domain_name=user.mastodon_site).first()
stars = rating_to_emoji(
mark.rating_grade,
site.star_mode if site else 0,
)
spoiler_text, txt = get_spoiler_text(mark.comment_text or "", mark.item)
content = f"{mark.get_action_for_feed()} {stars}\n{mark.item.absolute_url}\n{txt}{mark.tag_text}"
update_id = (
None
if post_as_new
else get_status_id_by_url((mark.shelfmember.metadata or {}).get("shared_link"))
)
response = post_toot(
user.mastodon_site,
content,
visibility,
user.mastodon_token,
False,
update_id,
spoiler_text,
)
if response is not None and response.status_code in [200, 201]:
j = response.json()
if "url" in j:
mark.shelfmember.metadata = {"shared_link": j["url"]}
mark.shelfmember.save(update_fields=["metadata"])
return True, 200
else:
logger.warning(response)
return False, response.status_code if response is not None else -1
def share_collection(collection, comment, user, visibility_no, link):
visibility = get_toot_visibility(visibility_no, user)
tags = (
"\n"
+ user.preference.mastodon_append_tag.replace("[category]", _("collection"))
if user.preference.mastodon_append_tag
else ""
)
user_str = (
_("shared my collection")
if user == collection.owner.user
else (
_("shared {username}'s collection").format(
username=(
" @" + collection.owner.user.mastodon_acct + " "
if collection.owner.user.mastodon_acct
else " " + collection.owner.username + " "
)
)
)
)
content = f"{user_str}:{collection.title}\n{link}\n{comment}{tags}"
response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token)
if response is not None and response.status_code in [200, 201]:
return True
else:
return False

View file

@ -1,6 +1,9 @@
from django.contrib.auth.backends import ModelBackend, UserModel
from django.http import HttpRequest
from .api import verify_account
from mastodon.models.common import SocialAccount
from .models import Mastodon
class OAuth2Backend(ModelBackend):
@ -10,23 +13,11 @@ class OAuth2Backend(ModelBackend):
# a user object that matches those credentials."
# arg request is an interface specification, not used in this implementation
def authenticate(self, request, username=None, password=None, **kwargs):
def authenticate(
self, request: HttpRequest | None, username=None, password=None, **kwargs
):
"""when username is provided, assume that token is newly obtained and valid"""
token = kwargs.get("token", None)
site = kwargs.get("site", None)
if token is None or site is None:
return
mastodon_username = None
if username is None:
code, user_data = verify_account(site, token)
if code == 200 and user_data:
mastodon_username = user_data.get("username")
if not mastodon_username:
return None
try:
user = UserModel._default_manager.get(
mastodon_username__iexact=mastodon_username, mastodon_site__iexact=site
)
return user if self.user_can_authenticate(user) else None
except UserModel.DoesNotExist:
account: SocialAccount = kwargs.get("social_account", None)
if not account or not account.user:
return None
return account.user if self.user_can_authenticate(account.user) else None

View file

@ -5,8 +5,7 @@ from django.utils import timezone
from loguru import logger
from common.models import BaseJob, JobManager
from mastodon.api import detect_server_info, verify_client
from mastodon.models import MastodonApplication
from mastodon.models import MastodonApplication, detect_server_info, verify_client
@JobManager.register

View file

@ -9,37 +9,6 @@ class Migration(migrations.Migration):
dependencies = []
operations = [
migrations.CreateModel(
name="CrossSiteUserInfo",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uid",
models.CharField(
max_length=200, verbose_name="username and original site"
),
),
(
"local_id",
models.PositiveIntegerField(verbose_name="local database id"),
),
(
"target_site",
models.CharField(
max_length=100, verbose_name="target site domain name"
),
),
("site_id", models.CharField(max_length=100)),
],
),
migrations.CreateModel(
name="MastodonApplication",
fields=[
@ -93,10 +62,4 @@ class Migration(migrations.Migration):
("proxy_to", models.CharField(blank=True, default="", max_length=100)),
],
),
migrations.AddConstraint(
model_name="crosssiteuserinfo",
constraint=models.UniqueConstraint(
fields=("uid", "target_site"), name="unique_cross_site_user_info"
),
),
]

View file

@ -0,0 +1,136 @@
# Generated by Django 4.2.13 on 2024-06-29 03:41
import django.db.models.deletion
import django.db.models.functions.text
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("mastodon", "0004_alter_mastodonapplication_api_domain_and_more"),
]
operations = [
migrations.CreateModel(
name="SocialAccount",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"type",
models.CharField(
choices=[
("mastodon.blueskyaccount", "bluesky account"),
("mastodon.emailaccount", "email account"),
("mastodon.mastodonaccount", "mastodon account"),
("mastodon.threadsaccount", "threads account"),
],
db_index=True,
max_length=255,
),
),
("domain", models.CharField(max_length=255)),
("uid", models.CharField(max_length=255)),
("handle", models.CharField(max_length=1000)),
("access_data", models.JSONField(default=dict)),
("account_data", models.JSONField(default=dict)),
("preference_data", models.JSONField(default=dict)),
("followers", models.JSONField(default=list)),
("following", models.JSONField(default=list)),
("mutes", models.JSONField(default=list)),
("blocks", models.JSONField(default=list)),
("domain_blocks", models.JSONField(default=list)),
("created", models.DateTimeField(default=django.utils.timezone.now)),
("modified", models.DateTimeField(auto_now=True)),
("last_refresh", models.DateTimeField(default=None, null=True)),
("last_reachable", models.DateTimeField(default=None, null=True)),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="social_accounts",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="BlueskyAccount",
fields=[],
options={
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=("mastodon.socialaccount",),
),
migrations.CreateModel(
name="EmailAccount",
fields=[],
options={
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=("mastodon.socialaccount",),
),
migrations.CreateModel(
name="MastodonAccount",
fields=[],
options={
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=("mastodon.socialaccount",),
),
migrations.CreateModel(
name="ThreadsAccount",
fields=[],
options={
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=("mastodon.socialaccount",),
),
migrations.AddIndex(
model_name="socialaccount",
index=models.Index(
fields=["type", "handle"], name="index_social_type_handle"
),
),
migrations.AddIndex(
model_name="socialaccount",
index=models.Index(
fields=["type", "domain", "uid"], name="index_social_type_domain_uid"
),
),
migrations.AddConstraint(
model_name="socialaccount",
constraint=models.UniqueConstraint(
django.db.models.functions.text.Lower("domain"),
django.db.models.functions.text.Lower("uid"),
name="unique_social_domain_uid",
),
),
migrations.AddConstraint(
model_name="socialaccount",
constraint=models.UniqueConstraint(
models.F("type"),
django.db.models.functions.text.Lower("handle"),
name="unique_social_type_handle",
),
),
]

View file

@ -1,26 +0,0 @@
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class MastodonApplication(models.Model):
domain_name = models.CharField(_("site domain name"), max_length=200, unique=True)
api_domain = models.CharField(_("domain for api call"), max_length=200, blank=True)
server_version = models.CharField(_("type and verion"), max_length=200, blank=True)
app_id = models.CharField(_("in-site app id"), max_length=200)
client_id = models.CharField(_("client id"), max_length=200)
client_secret = models.CharField(_("client secret"), max_length=200)
vapid_key = models.CharField(_("vapid key"), max_length=200, null=True, blank=True)
star_mode = models.PositiveIntegerField(
_("0: custom emoji; 1: unicode moon; 2: text"), blank=False, default=0
)
max_status_len = models.PositiveIntegerField(
_("max toot len"), blank=False, default=500
)
last_reachable_date = models.DateTimeField(null=True, default=None)
disabled = models.BooleanField(default=False)
is_proxy = models.BooleanField(default=False, blank=True)
proxy_to = models.CharField(max_length=100, blank=True, default="")
def __str__(self):
return self.domain_name

View file

@ -0,0 +1,12 @@
from .bluesky import Bluesky, BlueskyAccount
from .common import Platform, SocialAccount
from .email import Email, EmailAccount
from .mastodon import (
Mastodon,
MastodonAccount,
MastodonApplication,
detect_server_info,
get_spoiler_text,
verify_client,
)
from .threads import Threads, ThreadsAccount

View file

@ -0,0 +1,15 @@
from catalog.common import jsondata
from .common import SocialAccount
class Bluesky:
pass
class BlueskyAccount(SocialAccount):
username = jsondata.CharField(json_field_name="access_data", default="")
app_password = jsondata.EncryptedTextField(
json_field_name="access_data", default=""
)
pass

101
mastodon/models/common.py Normal file
View file

@ -0,0 +1,101 @@
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
class Platform(models.TextChoices):
EMAIL = "email", _("Email")
MASTODON = "mastodon", _("Mastodon")
THREADS = "threads", _("Threads")
BLUESKY = "bluesky", _("Bluesky")
class SocialAccount(TypedModel):
user = models.ForeignKey(
"users.User",
on_delete=models.CASCADE,
related_name="social_accounts",
null=True,
)
domain = models.CharField(max_length=255, null=False, blank=False)
# unique permanent id per domain per platform
uid = models.CharField(max_length=255, null=False, blank=False)
handle = models.CharField(max_length=1000, null=False, blank=False)
access_data = models.JSONField(default=dict, null=False)
account_data = models.JSONField(default=dict, null=False)
preference_data = models.JSONField(default=dict, null=False)
followers = models.JSONField(default=list)
following = models.JSONField(default=list)
mutes = models.JSONField(default=list)
blocks = models.JSONField(default=list)
domain_blocks = models.JSONField(default=list)
created = models.DateTimeField(default=timezone.now)
modified = models.DateTimeField(auto_now=True)
last_refresh = models.DateTimeField(default=None, null=True)
last_reachable = models.DateTimeField(default=None, null=True)
sync_profile = jsondata.BooleanField(
json_field_name="preference_data", default=True
)
sync_graph = jsondata.BooleanField(json_field_name="preference_data", default=True)
sync_timeline = jsondata.BooleanField(
json_field_name="preference_data", default=True
)
class Meta:
indexes = [
models.Index(fields=["type", "handle"], name="index_social_type_handle"),
models.Index(
fields=["type", "domain", "uid"], name="index_social_type_domain_uid"
),
]
constraints = [
models.UniqueConstraint(
Lower("domain"), Lower("uid"), name="unique_social_domain_uid"
),
models.UniqueConstraint(
"type", Lower("handle"), name="unique_social_type_handle"
),
]
def __str__(self) -> str:
return f"{self.platform}:{self.handle}"
@property
def platform(self) -> Platform:
return Platform(self.type.replace("mastodon.", "", 1).replace("account", "", 1))
def to_dict(self):
# skip cached_property, datetime and other non-serializable fields
d = {
k: v
for k, v in self.__dict__.items()
if k
not in [
"_state",
"api_domain",
"created",
"modified",
"last_refresh",
"last_reachable",
]
}
return d
@classmethod
def from_dict(cls, d: dict | None):
return cls(**d) if d else None
def check_alive(self) -> bool:
return False
def sync(self) -> bool:
return False

95
mastodon/models/email.py Normal file
View file

@ -0,0 +1,95 @@
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.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
class EmailAccount(SocialAccount):
pass
class Email:
@staticmethod
def _send(email, subject, body):
try:
logger.debug(f"Sending email to {email} with subject {subject}")
send_mail(
subject=subject,
message=body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[email],
fail_silently=False,
)
except Exception as e:
logger.error(f"send email {email} failed", extra={"exception": e})
@staticmethod
def generate_login_email(email: str, action: str) -> tuple[str, str]:
if action != "verify":
account = EmailAccount.objects.filter(handle__iexact=email).first()
action = "register" if account and account.user else "login"
s = {"e": email, "a": action}
# v = TimestampSigner().sign_object(s)
code = b62_encode(random.randint(pow(62, 4), pow(62, 5) - 1))
cache.set(f"login_{code}", s, timeout=_code_ttl)
footer = _(
"\n\nIf you did not mean to register or login, please ignore this email. If you are concerned with your account security, please change the email linked with your account, or contact us."
)
site = settings.SITE_INFO["site_name"]
match action:
case "verify":
subject = f'{site} - {_("Verification Code")} - {code}'
msg = _(
"Use this code to verify your email address {email}\n\n{code}"
).format(email=email, code=code)
case "login":
subject = f'{site} - {_("Verification Code")} - {code}'
msg = _("Use this code to login as {email}\n\n{code}").format(
email=email, code=code
)
case "register":
subject = f'{site} - {_("Register")}'
msg = _(
"There is no account registered with this email address yet: {email}\n\nIf you already have an account with us, just login and add this email to you account.\n\nIf you prefer to register a new account with this email, please use this verification code: {code}"
).format(email=email, code=code)
return subject, msg + footer
@staticmethod
def send_login_email(request: HttpRequest, email: str, action: str):
request.session["pending_email"] = email
subject, body = Email.generate_login_email(email, action)
django_rq.get_queue("mastodon").enqueue(Email._send, email, subject, body)
@staticmethod
def authenticate(request: HttpRequest, code: str) -> EmailAccount | None:
if not request.session.get("pending_email"):
return None
s: dict = cache.get(f"login_{code}")
email = (s or {}).get("e")
if not email or request.session.get("pending_email") != email:
return None
cache.delete(f"login_{code}")
del request.session["pending_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

840
mastodon/models/mastodon.py Normal file
View file

@ -0,0 +1,840 @@
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
from django.urls import reverse
from django.utils import timezone
# from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from loguru import logger
from catalog.common import jsondata
from .common import SocialAccount
if typing.TYPE_CHECKING:
from journal.models.common import VisibilityType
class TootVisibilityEnum(StrEnum):
PUBLIC = "public"
PRIVATE = "private"
DIRECT = "direct"
UNLISTED = "unlisted"
# See https://docs.joinmastodon.org/methods/accounts/
# returns user info
# retruns the same info as verify account credentials
# GET
API_GET_ACCOUNT = "/api/v1/accounts/:id"
# returns user info if valid, 401 if invalid
# GET
API_VERIFY_ACCOUNT = "/api/v1/accounts/verify_credentials"
# obtain token
# GET
API_OBTAIN_TOKEN = "/oauth/token"
# obatin auth code
# GET
API_OAUTH_AUTHORIZE = "/oauth/authorize"
# revoke token
# POST
API_REVOKE_TOKEN = "/oauth/revoke"
# relationships
# GET
API_GET_RELATIONSHIPS = "/api/v1/accounts/relationships"
# toot
# POST
API_PUBLISH_TOOT = "/api/v1/statuses"
# create new app
# POST
API_CREATE_APP = "/api/v1/apps"
# search
# GET
API_SEARCH = "/api/v2/search"
USER_AGENT = settings.NEODB_USER_AGENT
def get_api_domain(domain):
app = MastodonApplication.objects.filter(domain_name=domain).first()
return app.api_domain if app and app.api_domain else domain
# low level api below
def boost_toot(domain, token, toot_url):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {token}",
}
url = (
"https://"
+ domain
+ API_SEARCH
+ "?type=statuses&resolve=true&q="
+ quote(toot_url)
)
try:
response = get(url, headers=headers)
if response.status_code != 200:
logger.warning(
f"Error search {toot_url} on {domain} {response.status_code}"
)
return None
j = response.json()
if "statuses" in j and len(j["statuses"]) > 0:
s = j["statuses"][0]
url_id = toot_url.split("/posts/")[-1]
url_id2 = s["uri"].split("/posts/")[-1]
if s["uri"] != toot_url and s["url"] != toot_url and url_id != url_id2:
logger.warning(
f"Error status url mismatch {s['uri']} or {s['uri']} != {toot_url}"
)
return None
if s["reblogged"]:
logger.warning(f"Already boosted {toot_url}")
# TODO unboost and boost again?
return None
url = (
"https://"
+ domain
+ API_PUBLISH_TOOT
+ "/"
+ j["statuses"][0]["id"]
+ "/reblog"
)
response = post(url, headers=headers)
if response.status_code != 200:
logger.warning(
f"Error search {toot_url} on {domain} {response.status_code}"
)
return None
return response.json()
except Exception:
logger.warning(f"Error search {toot_url} on {domain}")
return None
def delete_toot(api_domain, access_token, toot_url):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {access_token}",
}
toot_id = get_status_id_by_url(toot_url)
url = "https://" + api_domain + API_PUBLISH_TOOT + "/" + toot_id
try:
response = delete(url, headers=headers)
if response.status_code != 200:
logger.warning(f"Error DELETE {url} {response.status_code}")
except Exception as e:
logger.warning(f"Error deleting {e}")
def post_toot2(
api_domain: str,
access_token: str,
content: str,
visibility: TootVisibilityEnum,
update_toot_url: str | None = None,
reply_to_toot_url: str | None = None,
sensitive: bool = False,
spoiler_text: str | None = None,
attachments: list = [],
):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {access_token}",
"Idempotency-Key": random_string_generator(16),
}
base_url = "https://" + api_domain
response = None
url = base_url + API_PUBLISH_TOOT
payload = {
"status": content,
"visibility": visibility,
}
update_id = get_status_id_by_url(update_toot_url)
reply_to_id = get_status_id_by_url(reply_to_toot_url)
if reply_to_id:
payload["in_reply_to_id"] = reply_to_id
if spoiler_text:
payload["spoiler_text"] = spoiler_text
if sensitive:
payload["sensitive"] = True
media_ids = []
for atta in attachments:
try:
media_id = (
post(
base_url + "/api/v1/media",
headers=headers,
data={},
files={"file": atta},
)
.json()
.get("id")
)
media_ids.append(media_id)
except Exception as e:
logger.warning(f"Error uploading image {e}")
headers["Idempotency-Key"] = random_string_generator(16)
if media_ids:
payload["media_ids[]"] = media_ids
try:
if update_id:
response = put(url + "/" + update_id, headers=headers, data=payload)
if not update_id or (response is not None and response.status_code != 200):
headers["Idempotency-Key"] = random_string_generator(16)
response = post(url, headers=headers, data=payload)
if response is not None and response.status_code != 200:
headers["Idempotency-Key"] = random_string_generator(16)
payload["in_reply_to_id"] = None
response = post(url, headers=headers, data=payload)
if response is not None and response.status_code == 201:
response.status_code = 200
if response is not None and response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
except Exception as e:
logger.warning(f"Error posting {e}")
response = None
return response
def _get_redirect_uris(allow_multiple=True) -> str:
u = settings.SITE_INFO["site_url"] + "/account/login/oauth"
if not allow_multiple:
return u
u2s = [f"https://{d}/account/login/oauth" for d in settings.ALTERNATIVE_DOMAINS]
return "\n".join([u] + u2s)
def create_app(domain_name, allow_multiple_redir):
url = "https://" + domain_name + API_CREATE_APP
payload = {
"client_name": settings.SITE_INFO["site_name"],
"scopes": settings.MASTODON_CLIENT_SCOPE,
"redirect_uris": _get_redirect_uris(allow_multiple_redir),
"website": settings.SITE_INFO["site_url"],
}
response = post(url, data=payload, headers={"User-Agent": USER_AGENT})
return response
def webfinger(site, username) -> dict | None:
url = f"https://{site}/.well-known/webfinger?resource=acct:{username}@{site}"
try:
response = get(url, headers={"User-Agent": USER_AGENT})
if response.status_code != 200:
logger.warning(f"Error webfinger {username}@{site} {response.status_code}")
return None
j = response.json()
return j
except Exception:
logger.warning(f"Error webfinger {username}@{site}")
return None
def random_string_generator(n):
s = string.ascii_letters + string.punctuation + string.digits
return "".join(random.choice(s) for i in range(n))
def rating_to_emoji(score, star_mode=0):
"""convert score to mastodon star emoji code"""
if score is None or score == "" or score == 0:
return ""
solid_stars = score // 2
half_star = int(bool(score % 2))
empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1
if star_mode == 1:
emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars
else:
emoji_code = (
settings.STAR_SOLID * solid_stars
+ settings.STAR_HALF * half_star
+ settings.STAR_EMPTY * empty_stars
)
emoji_code = emoji_code.replace("::", ": :")
emoji_code = " " + emoji_code + " "
return emoji_code
def verify_account(site, token):
url = "https://" + get_api_domain(site) + API_VERIFY_ACCOUNT
try:
response = get(
url, headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
)
return response.status_code, (
response.json() if response.status_code == 200 else None
)
except Exception:
return -1, None
def get_related_acct_list(site, token, api):
url = "https://" + get_api_domain(site) + api
results = []
while url:
try:
response = get(
url,
headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"},
)
url = None
if response.status_code == 200:
r: list[dict[str, str]] = response.json()
results.extend(
map(
lambda u: (
( # type: ignore
u["acct"]
if u["acct"].find("@") != -1
else u["acct"] + "@" + site
)
if "acct" in u
else u
),
r,
)
)
if "Link" in response.headers:
for ls in response.headers["Link"].split(","):
li = ls.strip().split(";")
if li[1].strip() == 'rel="next"':
url = li[0].strip().replace(">", "").replace("<", "")
except Exception as e:
logger.warning(f"Error GET {url} : {e}")
url = None
return results
def detect_server_info(login_domain: str) -> tuple[str, str, str]:
url = f"https://{login_domain}/api/v1/instance"
try:
response = get(url, headers={"User-Agent": USER_AGENT})
except Exception as e:
logger.error(f"Error connecting {login_domain}", extra={"exception": e})
raise Exception(f"Error connecting to instance {login_domain}")
if response.status_code != 200:
logger.error(f"Error connecting {login_domain}", extra={"response": response})
raise Exception(
f"Instance {login_domain} returned error code {response.status_code}"
)
try:
j = response.json()
domain = j["uri"].lower().split("//")[-1].split("/")[0]
except Exception as e:
logger.error(f"Error connecting {login_domain}", extra={"exception": e})
raise Exception(f"Instance {login_domain} returned invalid data")
server_version = j["version"]
api_domain = domain
if domain != login_domain:
url = f"https://{domain}/api/v1/instance"
try:
response = get(url, headers={"User-Agent": USER_AGENT})
j = response.json()
except Exception:
api_domain = login_domain
logger.info(
f"detect_server_info: {login_domain} {domain} {api_domain} {server_version}"
)
return domain, api_domain, server_version
def verify_client(mast_app):
payload = {
"client_id": mast_app.client_id,
"client_secret": mast_app.client_secret,
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
"scope": settings.MASTODON_CLIENT_SCOPE,
"grant_type": "client_credentials",
}
headers = {"User-Agent": USER_AGENT}
url = "https://" + (mast_app.api_domain or mast_app.domain_name) + API_OBTAIN_TOKEN
try:
response = post(
url, data=payload, headers=headers, timeout=settings.MASTODON_TIMEOUT
)
except Exception as e:
logger.warning(f"Error {url} {e}")
return False
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return False
data = response.json()
return data.get("access_token") is not None
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"))
payload = {
"client_id": mast_app.client_id,
"client_secret": mast_app.client_secret,
"redirect_uri": redirect_uri,
"scope": settings.MASTODON_CLIENT_SCOPE,
"grant_type": "authorization_code",
"code": code,
}
headers = {"User-Agent": USER_AGENT}
auth = None
if mast_app.is_proxy:
url = "https://" + mast_app.proxy_to + API_OBTAIN_TOKEN
else:
url = (
"https://"
+ (mast_app.api_domain or mast_app.domain_name)
+ API_OBTAIN_TOKEN
)
try:
response = post(url, data=payload, headers=headers, auth=auth)
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return None, None
except Exception as e:
logger.warning(f"Error {url} {e}")
return None, None
data = response.json()
return data.get("access_token"), data.get("refresh_token", "")
def get_status_id_by_url(url):
if not url:
return None
r = re.match(
r".+/(\w+)$", url
) # might be re.match(r'.+/([^/]+)$', u) if Pleroma supports edit
return r[1] if r else None
def get_spoiler_text(text, item):
if text.find(">!") != -1:
spoiler_text = _(
"regarding {item_title}, may contain spoiler or triggering content"
).format(item_title=item.display_title)
return spoiler_text, text.replace(">!", "").replace("!<", "")
else:
return None, text
def get_toot_visibility(visibility, user) -> TootVisibilityEnum:
if visibility == 2:
return TootVisibilityEnum.DIRECT
elif visibility == 1:
return TootVisibilityEnum.PRIVATE
elif user.preference.post_public_mode == 0:
return TootVisibilityEnum.PUBLIC
else:
return TootVisibilityEnum.UNLISTED
get = functools.partial(requests.get, timeout=settings.MASTODON_TIMEOUT)
put = functools.partial(requests.put, timeout=settings.MASTODON_TIMEOUT)
post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT)
delete = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT)
_sites_cache_key = "login_sites"
def get_or_create_fediverse_application(login_domain):
domain = login_domain
app = MastodonApplication.objects.filter(domain_name__iexact=domain).first()
if not app:
app = MastodonApplication.objects.filter(api_domain__iexact=domain).first()
if app:
return app
if not settings.MASTODON_ALLOW_ANY_SITE:
logger.warning(f"Disallowed to create app for {domain}")
raise ValueError("Unsupported instance")
if login_domain.lower() in settings.SITE_DOMAINS:
raise ValueError("Unsupported instance")
domain, api_domain, server_version = detect_server_info(login_domain)
if (
domain.lower() in settings.SITE_DOMAINS
or api_domain.lower() in settings.SITE_DOMAINS
):
raise ValueError("Unsupported instance")
if "neodb/" in server_version:
raise ValueError("Unsupported instance type")
if login_domain != domain:
app = MastodonApplication.objects.filter(domain_name__iexact=domain).first()
if app:
return app
allow_multiple_redir = True
if "; Pixelfed" in server_version or server_version.startswith("0."):
# Pixelfed and GoToSocial don't support multiple redirect uris
allow_multiple_redir = False
response = create_app(api_domain, allow_multiple_redir)
if response.status_code != 200:
logger.error(
f"Error creating app for {domain} on {api_domain}: {response.status_code}"
)
raise Exception("Error creating app, code: " + str(response.status_code))
try:
data = response.json()
except Exception:
logger.error(f"Error creating app for {domain}: unable to parse response")
raise Exception("Error creating app, invalid response")
app = MastodonApplication.objects.create(
domain_name=domain.lower(),
api_domain=api_domain.lower(),
server_version=server_version,
app_id=data["id"],
client_id=data["client_id"],
client_secret=data["client_secret"],
vapid_key=data.get("vapid_key", ""),
)
# create a client token to avoid vacuum by Mastodon 4.2+
try:
verify_client(app)
except Exception as e:
logger.error(f"Error creating client token for {domain}", extra={"error": e})
return app
def get_mastodon_login_url(app, login_domain, request):
url = request.build_absolute_uri(reverse("users:login_oauth"))
version = app.server_version or ""
scope = (
settings.MASTODON_LEGACY_CLIENT_SCOPE
if "Pixelfed" in version
else settings.MASTODON_CLIENT_SCOPE
)
return (
"https://"
+ login_domain
+ "/oauth/authorize?client_id="
+ app.client_id
+ "&scope="
+ quote(scope)
+ "&redirect_uri="
+ url
+ "&response_type=code"
)
class MastodonApplication(models.Model):
domain_name = models.CharField(_("site domain name"), max_length=200, unique=True)
api_domain = models.CharField(_("domain for api call"), max_length=200, blank=True)
server_version = models.CharField(_("type and verion"), max_length=200, blank=True)
app_id = models.CharField(_("in-site app id"), max_length=200)
client_id = models.CharField(_("client id"), max_length=200)
client_secret = models.CharField(_("client secret"), max_length=200)
vapid_key = models.CharField(_("vapid key"), max_length=200, null=True, blank=True)
star_mode = models.PositiveIntegerField(
_("0: custom emoji; 1: unicode moon; 2: text"), blank=False, default=0
)
max_status_len = models.PositiveIntegerField(
_("max toot len"), blank=False, default=500
)
last_reachable_date = models.DateTimeField(null=True, default=None)
disabled = models.BooleanField(default=False)
is_proxy = models.BooleanField(default=False, blank=True)
proxy_to = models.CharField(max_length=100, blank=True, default="")
def __str__(self):
return self.domain_name
class Mastodon:
@staticmethod
def get_sites():
sites = cache.get(_sites_cache_key, [])
if not sites:
sites = list(
MastodonAccount.objects.values("domain")
.annotate(total=Count("domain"))
.order_by("-total")
.values_list("domain", flat=True)
)
cache.set(_sites_cache_key, sites, timeout=3600 * 8)
@staticmethod
def obtain_token(domain: str, code: str, request: HttpRequest):
return obtain_token(domain, code, request)
@staticmethod
def generate_auth_url(domain: str, request):
login_domain = (
domain.strip().lower().split("//")[-1].split("/")[0].split("@")[-1]
)
app = get_or_create_fediverse_application(login_domain)
if app.api_domain and app.api_domain != app.domain_name:
login_domain = app.api_domain
login_url = get_mastodon_login_url(app, login_domain, request)
request.session["mastodon_domain"] = app.domain_name
return login_url
@staticmethod
def authenticate(domain, access_token, refresh_token) -> "MastodonAccount | None":
mastodon_account = MastodonAccount()
mastodon_account.domain = domain
mastodon_account.access_token = access_token
mastodon_account.refresh_token = refresh_token
if mastodon_account.refresh(save=False):
existing_account = MastodonAccount.objects.filter(
uid=mastodon_account.uid,
domain=mastodon_account.domain,
).first()
if existing_account:
existing_account.access_token = mastodon_account.access_token
existing_account.refresh_token = mastodon_account.refresh_token
existing_account.account_data = mastodon_account.account_data
existing_account.save(update_fields=["access_data", "account_data"])
return existing_account
return mastodon_account
class MastodonAccount(SocialAccount):
class CrosspostMode(models.IntegerChoices):
BOOST = 0, _("Boost")
POST = 1, _("New Post")
access_token = jsondata.EncryptedTextField(
json_field_name="access_data", default=""
)
refresh_token = jsondata.EncryptedTextField(
json_field_name="access_data", default=""
)
display_name = jsondata.CharField(json_field_name="account_data", default="")
username = jsondata.CharField(json_field_name="account_data", default="")
avatar = jsondata.CharField(json_field_name="account_data", default="")
locked = jsondata.BooleanField(json_field_name="account_data", default=False)
note = jsondata.CharField(json_field_name="account_data", default="")
url = jsondata.CharField(json_field_name="account_data", default="")
crosspost_mode = jsondata.IntegerField(
json_field_name="preference_data", choices=CrosspostMode.choices, default=0
)
def webfinger(self) -> dict | None:
acct = self.handle
site = self.domain
url = f"https://{site}/.well-known/webfinger?resource=acct:{acct}"
try:
response = get(url, headers={"User-Agent": settings.NEODB_USER_AGENT})
if response.status_code != 200:
logger.warning(f"Error webfinger {acct} {response.status_code}")
return None
j = response.json()
return j
except Exception:
logger.warning(f"Error webfinger {acct}")
return None
@property
def application(self) -> MastodonApplication | None:
app = MastodonApplication.objects.filter(domain_name=self.domain).first()
return app
@functools.cached_property
def api_domain(self) -> str:
app = self.application
return app.api_domain if app else self.domain
def rating_to_emoji(self, rating_grade: int) -> str:
app = self.application
return rating_to_emoji(rating_grade, app.star_mode if app else 0)
def _get(self, url: str):
url = url if url.startswith("https://") else f"https://{self.api_domain}{url}"
headers = {
"User-Agent": settings.NEODB_USER_AGENT,
"Authorization": f"Bearer {self.access_token}",
}
return get(url, headers=headers)
def _post(self, url: str, data, files=None):
url = url if url.startswith("https://") else f"https://{self.api_domain}{url}"
return post(
url,
data=data,
files=files,
headers={
"User-Agent": settings.NEODB_USER_AGENT,
"Authorization": f"Bearer {self.access_token}",
"Idempotency-Key": random_string_generator(16),
},
)
def _delete(self, url: str, data, files=None):
url = url if url.startswith("https://") else f"https://{self.api_domain}{url}"
return delete(
url,
headers={
"User-Agent": settings.NEODB_USER_AGENT,
"Authorization": f"Bearer {self.access_token}",
},
)
def _put(self, url: str, data, files=None):
url = url if url.startswith("https://") else f"https://{self.api_domain}{url}"
return put(
url,
data=data,
files=files,
headers={
"User-Agent": settings.NEODB_USER_AGENT,
"Authorization": f"Bearer {self.access_token}",
"Idempotency-Key": random_string_generator(16),
},
)
def verify_account(self):
try:
response = self._get("/api/v1/accounts/verify_credentials")
return response.status_code, (
response.json() if response.status_code == 200 else None
)
except Exception:
return -1, None
def get_related_accounts(self, api_path):
if api_path in ["followers", "following"]:
url = f"/api/v1/accounts/{self.account_data['id']}/{api_path}"
else:
url = f"/api/v1/{api_path}"
results = []
while url:
try:
response = self._get(url)
url = None
if response.status_code == 200:
r: list[dict[str, str]] = response.json()
results.extend(
map(
lambda u: (
(
u["acct"]
if u["acct"].find("@") != -1
else u["acct"] + "@" + self.domain
)
if "acct" in u
else u
),
r,
)
)
if "Link" in response.headers:
for ls in response.headers["Link"].split(","):
li = ls.strip().split(";")
if li[1].strip() == 'rel="next"':
url = li[0].strip().replace(">", "").replace("<", "")
except Exception as e:
logger.warning(f"Error GET {url} : {e}")
url = None
return results
def check_alive(self, save=True):
self.last_refresh = timezone.now()
if not self.webfinger():
logger.warning(f"Unable to fetch web finger for {self}")
return False
self.last_reachable = timezone.now()
if save:
self.save(update_fields=["last_reachable"])
return True
def refresh(self, save=True):
code, mastodon_account = self.verify_account()
self.last_refresh = timezone.now()
if code == 401:
logger.warning(f"Refresh mastodon data error 401 for {self}")
# self.access_token = ""
# if save:
# self.save(update_fields=["access_data"])
return False
if not mastodon_account:
logger.warning(f"Refresh mastodon data error {code} for {self}")
return False
handle = f"{mastodon_account['username']}@{self.domain}"
uid = mastodon_account["username"]
if self.uid != uid:
if self.uid:
logger.warning(f"user id changed {self.uid} -> {uid}")
self.uid = uid
if self.handle != handle:
if self.handle:
logger.warning(f"username changed {self.handle} -> {handle}")
self.handle = handle
self.account_data = mastodon_account
if save:
self.save(update_fields=["uid", "handle", "account_data", "last_refresh"])
return True
def refresh_graph(self, save=True):
self.followers = self.get_related_accounts("followers")
self.following = self.get_related_accounts("following")
self.mutes = self.get_related_accounts("mutes")
self.blocks = self.get_related_accounts("blocks")
self.domain_blocks = self.get_related_accounts("domain_blocks")
if save:
self.save(
update_fields=[
"followers",
"following",
"mutes",
"blocks",
"domain_blocks",
]
)
def boost_later(self, post_url: str):
django_rq.get_queue("fetch").enqueue(
boost_toot, self.api_domain, self.access_token, post_url
)
def delete_later(self, post_url: str):
django_rq.get_queue("fetch").enqueue(
delete_toot, self.api_domain, self.access_token, post_url
)
def post(
self,
content: str,
visibility: "VisibilityType",
update_toot_url: str | None = None,
reply_to_toot_url: str | None = None,
sensitive: bool = False,
spoiler_text: str | None = None,
attachments: list = [],
) -> requests.Response | None:
v = get_toot_visibility(visibility, self.user)
return post_toot2(
self.api_domain,
self.access_token,
content,
v,
update_toot_url,
reply_to_toot_url,
sensitive,
spoiler_text,
attachments,
)

View file

@ -0,0 +1,9 @@
from .common import SocialAccount
class Threads:
pass
class ThreadsAccount(SocialAccount):
pass

View file

@ -1,21 +0,0 @@
from django.conf import settings
def rating_to_emoji(score, star_mode=0):
"""convert score to mastodon star emoji code"""
if score is None or score == "" or score == 0:
return ""
solid_stars = score // 2
half_star = int(bool(score % 2))
empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1
if star_mode == 1:
emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars
else:
emoji_code = (
settings.STAR_SOLID * solid_stars
+ settings.STAR_HALF * half_star
+ settings.STAR_EMPTY * empty_stars
)
emoji_code = emoji_code.replace("::", ": :")
emoji_code = " " + emoji_code + " "
return emoji_code

View file

@ -16,7 +16,7 @@ dependencies = [
"django-compressor",
"django-cors-headers",
"django-environ",
"django-hijack",
"django-hijack>=3.5.4",
"django-jsonform",
"django-maintenance-mode",
"django-markdownx",
@ -64,11 +64,11 @@ virtual = true
dev-dependencies = [
"pre-commit>=3.7.0",
"black~=24.4.2",
"django-stubs",
"django-stubs>=5.0.2",
"djlint~=1.34.1",
"isort~=5.13.2",
"lxml-stubs",
"pyright>=1.1.367",
"pyright>=1.1.369",
"ruff",
"mkdocs-material>=9.5.25",
]

View file

@ -88,7 +88,7 @@ django-bleach==3.1.0
django-compressor==4.4
django-cors-headers==4.3.1
django-environ==0.11.2
django-hijack==3.5.0
django-hijack==3.5.4
django-jsonform==2.22.0
django-maintenance-mode==0.21.1
django-markdownx==4.0.7
@ -221,7 +221,7 @@ pygments==2.18.0
# via mkdocs-material
pymdown-extensions==10.8.1
# via mkdocs-material
pyright==1.1.367
pyright==1.1.369
python-dateutil==2.9.0.post0
# via dateparser
# via django-auditlog

View file

@ -70,7 +70,7 @@ django-bleach==3.1.0
django-compressor==4.4
django-cors-headers==4.3.1
django-environ==0.11.2
django-hijack==3.5.0
django-hijack==3.5.4
django-jsonform==2.22.0
django-maintenance-mode==0.21.1
django-markdownx==4.0.7

View file

@ -7,8 +7,6 @@ from loguru import logger
from common.models import BaseJob, JobManager
from journal.models import Comment, Review, ShelfMember
from mastodon.api import detect_server_info
from mastodon.models import MastodonApplication
from takahe.models import Domain, Identity, Post

View file

@ -7,13 +7,10 @@ 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.cache import cache
from django.core.exceptions import BadRequest, ObjectDoesNotExist
from django.core.mail import send_mail
from django.core.signing import TimestampSigner, b62_decode, b62_encode
from django.core.validators import EmailValidator
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render
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 _
@ -23,32 +20,19 @@ from loguru import logger
from common.config import *
from common.utils import AuthedHttpRequest
from journal.models import remove_data_by_user
from mastodon.api import *
from mastodon.api import verify_account
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 *
# the 'login' page that user can see
require_http_methods(["GET"])
@require_http_methods(["GET"])
def login(request):
selected_site = request.GET.get("site", default="")
cache_key = "login_sites"
sites = cache.get(cache_key, [])
if not sites:
sites = list(
User.objects.filter(is_active=True)
.values("mastodon_site")
.annotate(total=Count("mastodon_site"))
.order_by("-total")
.values_list("mastodon_site", flat=True)
)
cache.set(cache_key, sites, timeout=3600 * 8)
# store redirect url in the cookie
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
@ -64,14 +48,18 @@ def login(request):
{
"sites": sites,
"scope": quote(settings.MASTODON_CLIENT_SCOPE),
"selected_site": selected_site,
"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", "")
@ -83,27 +71,16 @@ def connect(request):
"common/error.html",
{"msg": _("Invalid email address")},
)
user = User.objects.filter(email__iexact=login_email).first()
code = b62_encode(random.randint(pow(62, 4), pow(62, 5) - 1))
cache.set(f"login_{code}", login_email, timeout=60 * 15)
request.session["login_email"] = login_email
action = "login" if user else "register"
django_rq.get_queue("mastodon").enqueue(
send_verification_link,
user.pk if user else 0,
action,
login_email,
code,
)
Email.send_login_email(request, login_email, "login")
return render(
request,
"common/verify.html",
"users/verify.html",
{
"msg": _("Verification"),
"secondary_msg": _(
"Verification email is being sent, please check your inbox."
),
"action": action,
"action": "login",
},
)
login_domain = (
@ -124,14 +101,8 @@ def connect(request):
login_domain.strip().lower().split("//")[-1].split("/")[0].split("@")[-1]
)
try:
app = get_or_create_fediverse_application(login_domain)
if app.api_domain and app.api_domain != app.domain_name:
login_domain = app.api_domain
login_url = get_mastodon_login_url(app, login_domain, request)
request.session["mastodon_domain"] = app.domain_name
resp = redirect(login_url)
resp.set_cookie("mastodon_domain", app.domain_name)
return resp
login_url = Mastodon.generate_auth_url(login_domain, request)
return redirect(login_url)
except Exception as e:
return render(
request,
@ -167,7 +138,7 @@ def connect_redirect_back(request):
},
)
try:
token, refresh_token = obtain_token(site, request, code)
token, refresh_token = Mastodon.obtain_token(site, code, request)
except ObjectDoesNotExist:
raise BadRequest(_("Invalid instance domain"))
if not token:
@ -180,43 +151,44 @@ def connect_redirect_back(request):
},
)
if (
request.session.get("swap_login", False) and request.user.is_authenticated
): # swap login for existing user
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)
user: User = authenticate(request, token=token, site=site) # type: ignore
if user: # existing user
user.mastodon_token = token
user.mastodon_refresh_token = refresh_token
user.save(update_fields=["mastodon_token", "mastodon_refresh_token"])
return login_existing_user(request, user)
else: # newly registered user
code, user_data = verify_account(site, token)
if code != 200 or user_data is None:
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 account data from Fediverse instance."),
"secondary_msg": _("Invalid user."),
},
)
return register_new_user(
request,
username=(
None if settings.MASTODON_ALLOW_ANY_SITE else user_data["username"]
),
mastodon_username=user_data["username"],
mastodon_id=user_data["id"],
mastodon_site=site,
mastodon_token=token,
mastodon_refresh_token=refresh_token,
mastodon_account=user_data,
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, **param):
def register_new_user(request, account: SocialAccount):
if settings.INVITE_ONLY:
if not Takahe.verify_invite(request.session.get("invite")):
return render(
@ -227,14 +199,22 @@ def register_new_user(request, **param):
"secondary_msg": _("Registration is for invitation only"),
},
)
else:
del request.session["invite"]
new_user = User.register(**param)
request.session["new_user"] = True
auth_login(request, new_user)
response = redirect(reverse("users:register"))
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)
return response
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):
@ -252,7 +232,6 @@ def login_existing_user(request, existing_user):
@login_required
def logout(request):
# revoke_token(request.user.mastodon_site, request.user.mastodon_token)
return auth_logout(request)
@ -287,249 +266,175 @@ class RegistrationForm(forms.ModelForm):
return username
def clean_email(self):
email = self.cleaned_data.get("email")
email = self.cleaned_data.get("email", "").strip()
if (
email
and User.objects.filter(email__iexact=email)
.exclude(pk=self.instance.pk if self.instance else -1)
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
def send_verification_link(user_id, action, email, code=""):
s = {"i": user_id, "e": email, "a": action}
v = TimestampSigner().sign_object(s)
footer = _(
"\n\nIf you did not mean to register or login, please ignore this email. If you are concerned with your account security, please change the email linked with your account, or contact us."
)
site = settings.SITE_INFO["site_name"]
if action == "verify":
subject = f'{site} - {_("Verification")}'
url = settings.SITE_INFO["site_url"] + "/account/verify_email?c=" + v
msg = _("Click this link to verify your email address {email}\n{url}").format(
email=email, url=url, code=code
)
msg += footer
elif action == "login":
subject = f'{site} - {_("Login")} {code}'
url = settings.SITE_INFO["site_url"] + "/account/login/email?c=" + v
msg = _(
"Use this code to confirm login as {email}\n\n{code}\n\nOr click this link to login\n{url}"
).format(email=email, url=url, code=code)
msg += footer
elif action == "register":
subject = f'{site} - {_("Register")}'
url = settings.SITE_INFO["site_url"] + "/account/register_email?c=" + v
msg = _(
"There is no account registered with this email address yet.{email}\n\nIf you already have an account with a Fediverse identity, just login and add this email to you account.\n\n"
).format(email=email, url=url, code=code)
if settings.ALLOW_EMAIL_ONLY_ACCOUNT:
msg += _(
"\nIf you prefer to register a new account, please use this code: {code}\nOr click this link:\n{url}"
).format(email=email, url=url, code=code)
msg += footer
else:
raise ValueError("Invalid action")
try:
logger.info(f"Sending email to {email} with subject {subject}")
logger.debug(msg)
send_mail(
subject=subject,
message=msg,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[email],
fail_silently=False,
)
except Exception as e:
logger.error(f"send email {email} failed", extra={"exception": e})
@require_http_methods(["POST"])
@require_http_methods(["GET", "POST"])
def verify_code(request):
code = request.POST.get("code")
if request.method == "GET":
return render(request, "users/verify.html")
code = request.POST.get("code", "").strip()
if not code:
return render(
request,
"common/verify.html",
"users/verify.html",
{
"error": _("Invalid verification code"),
},
)
login_email = cache.get(f"login_{code}")
if not login_email or request.session.get("login_email") != login_email:
account = Email.authenticate(request, code)
if not account:
return render(
request,
"common/verify.html",
"users/verify.html",
{
"error": _("Invalid verification code"),
},
)
cache.delete(f"login_{code}")
user = User.objects.filter(email__iexact=login_email).first()
if user:
resp = login_existing_user(request, user)
else:
resp = register_new_user(request, username=None, email=login_email)
resp.set_cookie("mastodon_domain", "@")
return resp
def verify_email(request):
error = ""
try:
s = TimestampSigner().unsign_object(request.GET.get("c"), max_age=60 * 15)
except Exception as e:
logger.warning(f"login link invalid {e}")
error = _("Invalid verification link")
return render(
request, "users/verify_email.html", {"success": False, "error": error}
)
try:
email = s["e"]
action = s["a"]
if action == "verify":
user = User.objects.get(pk=s["i"])
if user.pending_email == email:
user.email = user.pending_email
user.pending_email = None
user.save(update_fields=["email", "pending_email"])
return render(
request, "users/verify_email.html", {"success": True, "user": user}
)
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:
error = _("Email mismatch")
elif action == "login":
user = User.objects.get(pk=s["i"])
if user.email == email:
return login_existing_user(request, user)
else:
error = _("Email mismatch")
elif action == "register":
user = User.objects.filter(email__iexact=email).first()
if user:
error = _("Email in use")
else:
return register_new_user(request, username=None, email=email)
except Exception as e:
logger.error("verify email error", extra={"exception": e, "s": s})
error = _("Unable to verify")
return render(
request, "users/verify_email.html", {"success": False, "error": error}
)
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)
@login_required
@require_http_methods(["GET", "POST"])
def register(request: AuthedHttpRequest):
form = None
if settings.MASTODON_ALLOW_ANY_SITE:
form = RegistrationForm(request.POST)
form.instance = (
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
)
if request.method == "GET" or not form:
return render(request, "users/register.html", {"form": form})
elif request.method == "POST":
username_changed = False
email_cleared = False
if not form.is_valid():
return render(request, "users/register.html", {"form": form})
if not request.user.username and form.cleaned_data["username"]:
if User.objects.filter(
),
)
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():
return render(
request,
"users/register.html",
{
"form": form,
"error": _("Username in use"),
},
)
request.user.username = form.cleaned_data["username"]
username_changed = True
if form.cleaned_data["email"]:
if form.cleaned_data["email"].lower() != (request.user.email or "").lower():
if User.objects.filter(
email__iexact=form.cleaned_data["email"]
).exists():
return render(
request,
"users/register.html",
{
"form": form,
"error": _("Email in use"),
},
)
request.user.pending_email = form.cleaned_data["email"]
error = _("Username in use")
else:
request.user.pending_email = None
elif request.user.email or request.user.pending_email:
request.user.pending_email = None
request.user.email = None
email_cleared = True
request.user.save()
if request.user.pending_email:
django_rq.get_queue("mastodon").enqueue(
send_verification_link,
request.user.pk,
"verify",
request.user.pending_email,
)
messages.add_message(
request,
messages.INFO,
_("Verification email is being sent, please check your inbox."),
)
if request.user.username and not request.user.identity_linked():
request.user.initialize()
if username_changed:
messages.add_message(request, messages.INFO, _("Username all set."))
if email_cleared:
messages.add_message(
request, messages.INFO, _("Email removed from account.")
)
if request.session.get("new_user"):
del request.session["new_user"]
return redirect(request.GET.get("next", reverse("common:home")))
# 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"]
code, data = verify_account(site, token)
account = Mastodon.authenticate(site, token, refresh_token)
current_user = request.user
if code == 200 and data is not None:
username = data["username"]
if (
username == current_user.mastodon_username
and site == current_user.mastodon_site
):
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:
try:
User.objects.get(
mastodon_username__iexact=username, mastodon_site__iexact=site
)
messages.add_message(
request,
messages.ERROR,
_("Unable to update login information: identity in use."),
)
except ObjectDoesNotExist:
current_user.mastodon_username = username
current_user.mastodon_id = data["id"]
current_user.mastodon_site = site
current_user.mastodon_token = token
current_user.mastodon_refresh_token = refresh_token
current_user.mastodon_account = data
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",
@ -541,19 +446,19 @@ def swap_login(request, token, site, 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.") + f" {username}@{site}",
)
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:data"))
return redirect(reverse("users:info"))
def clear_preference_cache(request):
@ -563,8 +468,8 @@ def clear_preference_cache(request):
def auth_login(request, user):
"""Decorates django ``login()``. Attach token to session."""
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)
@ -574,7 +479,6 @@ def auth_login(request, user):
def auth_logout(request):
"""Decorates django ``logout()``. Release token in session."""
auth.logout(request)
response = redirect("/")
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)

View file

@ -147,7 +147,7 @@ def export_marks(request):
@login_required
def sync_mastodon(request):
if request.method == "POST" and request.user.mastodon_username:
if request.method == "POST" and request.user.mastodon:
django_rq.get_queue("mastodon").enqueue(
refresh_mastodon_data_task, request.user.pk
)

View file

@ -1,5 +1,7 @@
from datetime import timedelta
from enum import IntEnum
from django.db.models import F
from django.utils import timezone
from loguru import logger
@ -9,35 +11,34 @@ from users.models import User
@JobManager.register
class MastodonUserSync(BaseJob):
batch = 16
interval_hours = 3
interval = timedelta(hours=interval_hours)
def run(self):
logger.info("Mastodon User Sync start.")
inactive_threshold = timezone.now() - timedelta(days=90)
batch = (24 + self.interval_hours - 1) // self.interval_hours
if batch < 1:
batch = 1
m = timezone.now().hour // self.interval_hours
qs = (
User.objects.exclude(
preference__mastodon_skip_userinfo=True,
preference__mastodon_skip_relationship=True,
)
.filter(
mastodon_last_refresh__lt=timezone.now()
- timedelta(hours=self.interval_hours * self.batch)
)
.filter(
username__isnull=False,
is_active=True,
)
.exclude(mastodon_token__isnull=True)
.exclude(mastodon_token="")
.annotate(idmod=F("id") % batch)
.filter(idmod=m)
)
for user in qs.iterator():
skip_detail = False
if not user.last_login or user.last_login < inactive_threshold:
last_usage = user.last_usage
if not last_usage or last_usage < inactive_threshold:
logger.warning(f"Skip {user} detail because of inactivity.")
logger.info(f"Skip {user} detail because of inactivity.")
skip_detail = True
user.refresh_mastodon_data(skip_detail)
user.refresh_mastodon_data(skip_detail, self.interval_hours)
logger.info("Mastodon User Sync finished.")

View file

@ -0,0 +1,51 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from tqdm import tqdm
from catalog.common import jsondata
from mastodon.models import Email, MastodonAccount, mastodon
from mastodon.models.email import EmailAccount
from users.models import Preference, User
class Command(BaseCommand):
def handle(self, *args, **options):
m = 0
e = 0
for user in tqdm(User.objects.filter(is_active=True)):
if user.mastodon_username:
MastodonAccount.objects.update_or_create(
handle=f"{user.mastodon_username}@{user.mastodon_site}",
defaults={
"user": user,
"uid": user.mastodon_username,
"domain": user.mastodon_site,
"created": user.date_joined,
"last_refresh": user.mastodon_last_refresh,
"last_reachable": user.mastodon_last_reachable,
"followers": user.mastodon_followers,
"following": user.mastodon_following,
"blocks": user.mastodon_blocks,
"mutes": user.mastodon_mutes,
"domain_blocks": user.mastodon_domain_blocks,
"account_data": user.mastodon_account,
"access_data": {
"access_token": jsondata.encrypt_str(user.mastodon_token)
},
},
)
m += 1
if user.email:
EmailAccount.objects.update_or_create(
handle=user.email,
defaults={
"user": user,
"uid": user.email.split("@")[0],
"domain": user.email.split("@")[1],
"created": user.date_joined,
},
)
e += 1
print(f"{m} Mastodon, {e} Email migrated.")

View file

@ -4,6 +4,7 @@ from django.conf import settings
from django.db import models
from django.templatetags.static import static
from mastodon.models.mastodon import MastodonAccount
from takahe.utils import Takahe
from .preference import Preference
@ -17,9 +18,10 @@ class APIdentity(models.Model):
This model is used as 1:1 mapping to Takahe Identity Model
"""
user: User
user = models.OneToOneField(
"User", models.SET_NULL, related_name="identity", null=True
)
User, models.SET_NULL, related_name="identity", null=True
) # type:ignore
local = models.BooleanField()
username = models.CharField(max_length=500, blank=True, null=True)
domain_name = models.CharField(max_length=500, blank=True, null=True)
@ -246,23 +248,24 @@ class APIdentity(models.Model):
)
elif sl == 2:
if match_linked:
return cls.objects.get(
user__mastodon_username__iexact=s[0],
user__mastodon_site__iexact=s[1],
deleted__isnull=True,
)
i = MastodonAccount.objects.get(
handle__iexact=handler,
).user.identity
if i.deleted:
raise cls.DoesNotExist(f"Identity deleted {handler}")
return i
else:
i = cls.get_remote(s[0], s[1])
if i:
return i
raise cls.DoesNotExist(f"Identity not found @{handler}")
raise cls.DoesNotExist(f"Identity not found {handler}")
elif sl == 3 and s[0] == "":
i = cls.get_remote(s[1], s[2])
if i:
return i
raise cls.DoesNotExist(f"Identity not found {handler}")
else:
raise cls.DoesNotExist(f"Identity handler invalid {handler}")
raise cls.DoesNotExist(f"Identity handle invalid {handler}")
@cached_property
def activity_manager(self):

View file

@ -9,7 +9,7 @@ from django.contrib.auth.models import AbstractUser, BaseUserManager
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
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.urls import reverse
@ -18,10 +18,12 @@ from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
from loguru import logger
from mastodon.api import *
from mastodon.models import EmailAccount, MastodonAccount, Platform, SocialAccount
from takahe.utils import Takahe
if TYPE_CHECKING:
from mastodon.models import Mastodon
from .apidentity import APIdentity
from .preference import Preference
@ -76,6 +78,8 @@ class UserManager(BaseUserManager):
class User(AbstractUser):
identity: "APIdentity"
preference: "Preference"
social_accounts: "models.QuerySet[SocialAccount]"
objects: ClassVar[UserManager] = UserManager()
username_validator = UsernameValidator()
username = models.CharField(
_("username"),
@ -88,6 +92,15 @@ class User(AbstractUser):
"unique": _("A user with that username already exists."),
},
)
language = models.CharField(
_("language"),
max_length=10,
choices=settings.LANGUAGES,
null=False,
default="en",
)
# remove the following
email = models.EmailField(
_("email address"),
unique=True,
@ -97,13 +110,6 @@ class User(AbstractUser):
pending_email = models.EmailField(
_("email address pending verification"), default=None, null=True
)
language = models.CharField(
_("language"),
max_length=10,
choices=settings.LANGUAGES,
null=False,
default="en",
)
local_following = models.ManyToManyField(
through="Follow",
to="self",
@ -146,7 +152,6 @@ class User(AbstractUser):
# store the latest read announcement id,
# every time user read the announcement update this field
read_announcement_index = models.PositiveIntegerField(default=0)
objects: ClassVar[UserManager] = UserManager()
class Meta:
constraints = [
@ -182,25 +187,24 @@ class User(AbstractUser):
]
@cached_property
def mastodon_acct(self):
return (
f"{self.mastodon_username}@{self.mastodon_site}"
if self.mastodon_username
else ""
)
def mastodon(self) -> "MastodonAccount | None":
return MastodonAccount.objects.filter(user=self).first()
@property
@cached_property
def email_account(self) -> "EmailAccount | None":
return EmailAccount.objects.filter(user=self).first()
@cached_property
def mastodon_acct(self):
return self.mastodon.handle if self.mastodon else ""
@cached_property
def locked(self):
return self.mastodon_locked
return self.identity.locked
@property
def display_name(self):
return (
(self.mastodon_account.get("display_name") if self.mastodon_account else "")
or self.username
or self.mastodon_acct
or ""
)
return self.identity.display_name
@property
def avatar(self):
@ -208,22 +212,16 @@ class User(AbstractUser):
self.identity.avatar if self.identity else settings.SITE_INFO["user_icon"]
)
@property
def handler(self):
return (
f"{self.username}" if self.username else self.mastodon_acct or f"~{self.pk}"
)
@property
def url(self):
return reverse("journal:user_profile", args=[self.handler])
return reverse("journal:user_profile", args=[self.username])
@property
def absolute_url(self):
return settings.SITE_INFO["site_url"] + self.url
def __str__(self):
return f'USER:{self.pk}:{self.username or "<missing>"}:{self.mastodon_acct or self.email}'
return f'USER:{self.pk}:{self.username or "<missing>"}:{self.mastodon or self.email_account or ""}'
@property
def registration_complete(self):
@ -243,66 +241,78 @@ class User(AbstractUser):
def clear(self):
if self.mastodon_site == "removed" and not self.is_active:
return
self.first_name = self.mastodon_acct or ""
self.last_name = self.email or ""
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()
with transaction.atomic():
self.first_name = self.mastodon_acct or ""
self.last_name = self.email or ""
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()
def sync_relationship(self):
from .apidentity import APIdentity
def get_identities(accts: list):
q = None
for acct in accts or []:
t = acct.split("@") if acct else []
if len(t) == 2:
if q:
q = q | Q(
user__mastodon_username=t[0], user__mastodon_site=t[1]
)
else:
q = Q(user__mastodon_username=t[0], user__mastodon_site=t[1])
if not q:
return APIdentity.objects.none()
return APIdentity.objects.filter(q).filter(user__is_active=True)
def get_identity_ids(accts: list):
return set(
MastodonAccount.objects.filter(handle__in=accts).values_list(
"user__identity", flat=True
)
)
for target_identity in get_identities(self.mastodon_following):
if not self.identity.is_following(target_identity):
self.identity.follow(target_identity, True)
for target_identity in get_identities(self.mastodon_blocks):
if not self.identity.is_blocking(target_identity):
self.identity.block(target_identity)
for target_identity in get_identities(self.mastodon_mutes):
if not self.identity.is_muting(target_identity):
self.identity.mute(target_identity)
def get_identity_ids_in_domains(domains: list):
return set(
MastodonAccount.objects.filter(domain__in=domains).values_list(
"user__identity", flat=True
)
)
me = self.identity.pk
if not self.mastodon:
return
for target_identity in get_identity_ids(self.mastodon.following):
if not Takahe.get_is_following(me, target_identity):
Takahe.follow(me, target_identity, True)
for target_identity in get_identity_ids(self.mastodon.blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
for target_identity in get_identity_ids_in_domains(self.mastodon.domain_blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
for target_identity in get_identity_ids(self.mastodon.mutes):
if not Takahe.get_is_muting(me, target_identity):
Takahe.mute(me, target_identity)
def sync_identity(self):
identity = self.identity.takahe_identity
if identity.deleted:
logger.error(f"Identity {identity} is deleted, skip sync")
return
acct = self.mastodon_account
identity.name = acct.get("display_name") or identity.name or identity.username
identity.summary = acct.get("note") or identity.summary
identity.manually_approves_followers = self.mastodon_locked
if not bool(identity.icon) or identity.icon_uri != acct.get("avatar"):
identity.icon_uri = acct.get("avatar")
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:
try:
r = httpx.get(identity.icon_uri)
@ -315,12 +325,18 @@ class User(AbstractUser):
)
identity.save()
def refresh_mastodon_data(self, skip_detail=False):
def refresh_mastodon_data(self, skip_detail=False, sleep_hours=0):
"""Try refresh account data from mastodon server, return True if refreshed successfully"""
mastodon = self.mastodon
if not mastodon:
return False
if mastodon.last_refresh and mastodon.last_refresh > timezone.now() - timedelta(
hours=sleep_hours
):
logger.debug(f"Skip refreshing Mastodon data for {self}")
return
logger.debug(f"Refreshing Mastodon data for {self}")
self.mastodon_last_refresh = timezone.now()
if not webfinger(self.mastodon_site, self.mastodon_username):
logger.warning(f"Unable to fetch web finger for {self}")
if not mastodon.check_alive():
if (
timezone.now() - self.mastodon_last_reachable
> timedelta(days=settings.DEACTIVATE_AFTER_UNREACHABLE_DAYS)
@ -328,61 +344,16 @@ class User(AbstractUser):
):
logger.warning(f"Deactivate {self} bc unable to reach for too long")
self.is_active = False
self.save(update_fields=["mastodon_last_refresh", "is_active"])
return False
self.mastodon_last_reachable = timezone.now()
self.save(update_fields=["mastodon_last_refresh", "mastodon_last_reachable"])
code, mastodon_account = verify_account(self.mastodon_site, self.mastodon_token)
if code == 401:
logger.warning(f"Refresh mastodon data error 401 for {self}")
self.mastodon_token = ""
self.save(update_fields=["mastodon_token"])
return False
if not mastodon_account:
logger.warning(f"Refresh mastodon data error {code} for {self}")
self.save(update_fields=["is_active"])
return False
if not mastodon.refresh():
return False
if skip_detail:
return True
self.mastodon_account = mastodon_account
self.mastodon_locked = mastodon_account["locked"]
if self.mastodon_username != mastodon_account["username"]:
logger.warning(
f"username changed from {self} to {mastodon_account['username']}"
)
self.mastodon_username = mastodon_account["username"]
self.mastodon_followers = get_related_acct_list(
self.mastodon_site,
self.mastodon_token,
f"/api/v1/accounts/{self.mastodon_id}/followers",
)
self.mastodon_following = get_related_acct_list(
self.mastodon_site,
self.mastodon_token,
f"/api/v1/accounts/{self.mastodon_id}/following",
)
self.mastodon_mutes = get_related_acct_list(
self.mastodon_site, self.mastodon_token, "/api/v1/mutes"
)
self.mastodon_blocks = get_related_acct_list(
self.mastodon_site, self.mastodon_token, "/api/v1/blocks"
)
self.mastodon_domain_blocks = get_related_acct_list(
self.mastodon_site, self.mastodon_token, "/api/v1/domain_blocks"
)
self.save(
update_fields=[
"mastodon_account",
"mastodon_locked",
"mastodon_followers",
"mastodon_following",
"mastodon_mutes",
"mastodon_blocks",
"mastodon_domain_blocks",
]
)
if not self.preference.mastodon_skip_userinfo:
self.sync_identity()
if not self.preference.mastodon_skip_relationship:
mastodon.refresh_graph()
self.sync_relationship()
return True
@ -411,65 +382,35 @@ class User(AbstractUser):
return self.identity.tag_manager
@classmethod
def get(cls, name, case_sensitive=False):
if isinstance(name, str):
if name.startswith("~"):
try:
query_kwargs = {"pk": int(name[1:])}
except Exception:
return None
elif name.startswith("@"):
query_kwargs = {
"username__iexact" if case_sensitive else "username": name[1:]
}
else:
sp = name.split("@")
if len(sp) == 2:
query_kwargs = {
(
"mastodon_username__iexact"
if case_sensitive
else "mastodon_username"
): sp[0],
(
"mastodon_site__iexact"
if case_sensitive
else "mastodon_site"
): sp[1],
}
else:
return None
elif isinstance(name, int):
query_kwargs = {"pk": name}
else:
return None
return User.objects.filter(**query_kwargs).first()
@classmethod
def register(cls, **param):
def register(cls, **param) -> "User":
from .preference import Preference
new_user = cls(**param)
if "language" not in param:
new_user.language = translation.get_language()
new_user.save()
Preference.objects.create(user=new_user)
if new_user.username: # TODO make username required in registeration
new_user.initialize()
return new_user
def identity_linked(self):
from .apidentity import APIdentity
return APIdentity.objects.filter(user=self).exists()
def initialize(self):
if not self.username:
raise ValueError("Username is not set")
Takahe.init_identity_for_local_user(self)
self.identity.shelf_manager
if self.mastodon_acct:
Takahe.fetch_remote_identity(self.mastodon_acct)
account = param.pop("account", None)
with transaction.atomic():
logger.debug(account.access_data)
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.save()
Preference.objects.create(user=new_user)
if account:
account.user = new_user
logger.debug(account.access_data)
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
# TODO the following models should be deprecated soon

View file

@ -3,13 +3,11 @@ from loguru import logger
from .models import User
def refresh_mastodon_data_task(user_id, token=None):
def refresh_mastodon_data_task(user_id):
user = User.objects.get(pk=user_id)
if not user.mastodon_username:
if not user.mastodon:
logger.info(f"{user} mastodon data refresh skipped")
return
if token:
user.mastodon_token = token
if user.refresh_mastodon_data():
logger.info(f"{user} mastodon data refreshed")
else:

View file

@ -27,16 +27,20 @@
<input name="username" _="on input remove [@disabled] from #save end" placeholder="{% trans "2-30 alphabets, numbers or underscore, can't be changed once saved" %}" required {% if request.user.username %}value="{{ request.user.username }}" aria-invalid="false" readonly{% endif %} pattern="^[a-zA-Z0-9_]{2,30}$" />
</label>
<label>
{% trans "email address (optional if you log in via other Fediverse site, but recommended)" %}
{% trans "Email address" %}
<input type="email"
name="email"
_="on input remove [@disabled] from #save then remove [@aria-invalid] end"
{% if request.user.email %}value="{{ request.user.email }}" aria-invalid="false"{% endif %}
{% if request.user.email_account %}value="{{ request.user.email_account.handle }}" aria-invalid="false"{% endif %}
placeholder="email"
autocomplete="email" />
{% if request.user.pending_email %}
<small> {% blocktrans with pending_email=request.user.pending_email %}Please click the confirmation link in the email sent to {{ pending_email }}; if you haven't received it for more than a few minutes, please input and save again.{% endblocktrans %} </small>
{% endif %}
<small>
{% if request.session.pending_email %}
{% blocktrans with pending_email=request.session.pending_email %}Please click the confirmation link in the email sent to {{ pending_email }}; if you haven't received it for more than a few minutes, please input and save again.{% endblocktrans %}
{% elif not request.user.email_account %}
{% trans "Email is recommended as a backup login method, if you log in via a Fediverse instance" %}
{% endif %}
</small>
</label>
</fieldset>
{% csrf_token %}

View file

@ -51,11 +51,13 @@
<a href="{{ request.session.next_url | default:'/' }}" class="button">{% trans 'back to your home page.' %}</a>
{% elif allow_any_site %}
<div role="group">
<button class="platform outline" _="on click add .outline to .platform then remove .outline from me then hide
<form/>
then show #login-email" id="platform-email" title="{% trans "Email" %}">
<i class="fa-solid fa-envelope"></i>
</button>
{% if enable_email %}
<button class="platform outline" _="on click add .outline to .platform then remove .outline from me then hide
<form/>
then show #login-email" id="platform-email" title="{% trans "Email" %}">
<i class="fa-solid fa-envelope"></i>
</button>
{% endif %}
<button class="platform outline" _="on click add .outline to .platform then remove .outline from me then hide
<form/>
then show #login-mastodon" id="platform-mastodon" title="{% trans "Fediverse (Mastodon)" %}">
@ -68,12 +70,18 @@
</svg>
<!--<i class="fa-brands fa-mastodon"></i>-->
</button>
<!-- <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" %}">
<!--
{% 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" %}">
<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" %}">
<i class="fa-brands fa-bluesky" style="font-size:85%"></i>
</button> -->
</button>
{%endif%}
-->
</div>
<form id="login-email"
style="display:none"
@ -103,7 +111,7 @@
id="domain"
autofocus
pattern="(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,})"
placeholder="{% trans 'domain of your instance, e.g. mastodon.social' %}"
placeholder="{% trans 'Domain of your instance, e.g. mastodon.social' %}"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
@ -168,13 +176,13 @@
<footer>
<br>
<small>
{% blocktrans %}Using this site implies consent of our <a href="/pages/rules/">rules</a> and <a href="/pages/terms/">terms</a>, and use of cookies to provide necessary functionality.{% endblocktrans %}
{% blocktrans %}Continue using this site implies consent to our <a href="/pages/rules/">rules</a> and <a href="/pages/terms/">terms</a>, including using cookies to provide necessary functionality.{% endblocktrans %}
</small>
</footer>
{{ sites|json_script:"sites-data" }}
<script>
const sites = JSON.parse(document.getElementById('sites-data').textContent);
const autoCompleteJS = new autoComplete({ placeHolder: "{% trans 'select or input domain name of your instance (excl. @)' %}",
const autoCompleteJS = new autoComplete({ placeHolder: "{% trans 'Domain of your instance (excl. @)' %}",
selector: "#domain",
// wrapper: false,
data: {
@ -202,8 +210,12 @@
return true;
}
$(()=>{
var selected_domain = '{{selected_domain}}';
var method = localStorage.login_method;
if (!!method) {
if (!!selected_domain) {
method = "mastodon";
$('#domain').val(selected_domain);
} else if (!!method) {
$('#domain').val(localStorage.mastodon_domain);
$('#email').val(localStorage.email);
} else {

View file

@ -236,7 +236,7 @@
<a href="/@{{ request.user.identity.handle }}/settings/tokens/">{% trans "View authorized applications" %}</a>
</p>
<p>
{% if user.email %}
{% if user.email_account %}
<a href="/@{{ request.user.identity.handle }}/settings/migrate_in/">{% trans "Migrate account" %}</a>
{% else %}
<a href="{% url 'users:info' %}">{% trans "Link an email so that you can migrate followers from other Fediverse instances." %}</a>

View file

@ -14,14 +14,6 @@
<header style="text-align: center;">
<img src="{{ site_logo }}" class="logo" alt="logo">
</header>
{% if request.session.new_user %}
<h4>{% trans "Welcome" %}</h4>
<p>
{% blocktrans %}
{{ site_name }} is flourishing because of collaborations and contributions from users like you. Please read our <a href="/pages/terms">term of service</a>, and feel free to <a href="{{ support_link }}">contact us</a> if you have any question or feedback.
{% endblocktrans %}
</p>
{% endif %}
{% if form %}
<form action="{% url 'users:register' %}" method="post">
<small>{{ error }}</small>
@ -32,22 +24,30 @@
{% for error in form.username.errors %}<small>{{ error }}</small>{% endfor %}
</label>
<label>
{% trans "email address (optional if you log in via other Fediverse site, but recommended)" %}
{% trans "Email address" %}
<input type="email"
name="email"
{% if request.user.email and not request.user.mastodon_acct %}readonly{% endif %}
{% if request.user.email %}value="{{ request.user.email }}" aria-invalid="false"{% endif %}
{% if email_readonly %}readonly aria-invalid="false"{% endif %}
value="{{ form.email.value|default:'' }}"
placeholder="email"
autocomplete="email" />
{% if request.user.pending_email %}
<small> {% blocktrans with pending_email=request.user.pending_email %}Please click the confirmation link in the email sent to {{ pending_email }}; if you haven't received it for more than a few minutes, please input and save again.{% endblocktrans %} </small>
{% endif %}
{% for error in form.email.errors %}<small>{{ error }}</small>{% endfor %}
<small>
{% if request.session.pending_email %}
{% blocktrans with pending_email=request.session.pending_email %}Please click the confirmation link in the email sent to {{ pending_email }}; if you haven't received it for more than a few minutes, please input and save again.{% endblocktrans %}
<br>
{% elif not form.email.value %}
{% trans "Email is recommended as a backup login method, if you log in via a Fediverse instance" %}
<br>
{% endif %}
{% for error in form.email.errors %}
{{ error }}
<br>
{% endfor %}
</small>
</label>
</fieldset>
{% csrf_token %}
<input type="submit" value="{% trans 'Confirm and save' %}">
<small>{% trans "Once saved, click the confirmation link in the email you receive" %}</small>
</form>
{% else %}
<form action="{% url 'common:home' %}" method="get">

View file

@ -18,7 +18,7 @@
<header>
<h3>{% trans "Verification email is being sent, please check your inbox." %}</h3>
</header>
{% trans "Please click the login link in the email, or enter the verification code you received." %}
{% trans "Please enter the verification code you received." %}
<style type="text/css">
.otp input {
font-family: monospace;

View file

@ -0,0 +1,29 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} - {% trans 'Register' %}</title>
{% include "common_libs.html" %}
</head>
<body>
<div class="container">
<article>
<header style="text-align: center;">
<img src="{{ site_logo }}" class="logo" alt="logo">
</header>
<h4>{% trans "Welcome" %}</h4>
<p>
{% blocktrans %}
{{ site_name }} is flourishing because of collaborations and contributions from users like you. Please read our <a href="/pages/terms">term of service</a>, and feel free to <a href="{{ support_link }}">contact us</a> if you have any question or feedback.
{% endblocktrans %}
</p>
<form action="{{ request.session.next_url | default:'/' }}" method="get">
<input type="submit" value="{% trans 'Cut the sh*t and get me in!' %}">
</form>
</article>
</div>
</body>
</html>

View file

@ -8,10 +8,7 @@ app_name = "users"
urlpatterns = [
path("login", login, name="login"),
path("login/oauth", connect_redirect_back, name="login_oauth"),
path("login/email", verify_email, name="login_email"),
path("verify_email", verify_email, name="verify_email"),
path("verify_code", verify_code, name="verify_code"),
path("register_email", verify_email, name="register_email"),
path("register", register, name="register"),
path("connect", connect, name="connect"),
path("reconnect", reconnect, name="reconnect"),