From 8aef26a5884758fc8625c6b13a323c0569e6b3d8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jul 2024 17:29:38 -0400 Subject: [PATCH] refactor mastodon login --- boofilsic/settings.py | 17 +- catalog/common/jsondata.py | 49 +- catalog/templates/_item_user_pieces.html | 2 +- catalog/templates/catalog_history.html | 2 +- compose.yml | 1 - journal/models/common.py | 17 +- journal/models/mark.py | 53 +- journal/models/note.py | 6 +- journal/models/review.py | 1 - journal/views/collection.py | 62 +- journal/views/post.py | 5 +- journal/views/wrapped.py | 19 +- locale/zh_Hans/LC_MESSAGES/django.po | 527 ++++++----- locale/zh_Hant/LC_MESSAGES/django.po | 527 ++++++----- mastodon/api.py | 700 --------------- mastodon/auth.py | 29 +- mastodon/jobs.py | 3 +- mastodon/migrations/0001_initial.py | 37 - mastodon/migrations/0005_socialaccount.py | 136 +++ mastodon/models.py | 26 - mastodon/models/__init__.py | 12 + mastodon/models/bluesky.py | 15 + mastodon/models/common.py | 101 +++ mastodon/models/email.py | 95 ++ mastodon/models/mastodon.py | 840 ++++++++++++++++++ mastodon/models/threads.py | 9 + mastodon/utils.py | 21 - pyproject.toml | 6 +- requirements-dev.lock | 4 +- requirements.lock | 2 +- takahe/jobs.py | 2 - users/account.py | 488 ++++------ users/data.py | 2 +- users/jobs/sync.py | 19 +- users/management/commands/migrate_mastodon.py | 51 ++ users/models/apidentity.py | 21 +- users/models/user.py | 323 +++---- users/tasks.py | 6 +- users/templates/users/account.html | 14 +- users/templates/users/login.html | 34 +- users/templates/users/preferences.html | 2 +- users/templates/users/register.html | 32 +- .../templates/users}/verify.html | 2 +- users/templates/users/welcome.html | 29 + users/urls.py | 3 - 45 files changed, 2363 insertions(+), 1989 deletions(-) create mode 100644 mastodon/migrations/0005_socialaccount.py delete mode 100644 mastodon/models.py create mode 100644 mastodon/models/__init__.py create mode 100644 mastodon/models/bluesky.py create mode 100644 mastodon/models/common.py create mode 100644 mastodon/models/email.py create mode 100644 mastodon/models/mastodon.py create mode 100644 mastodon/models/threads.py delete mode 100644 mastodon/utils.py create mode 100644 users/management/commands/migrate_mastodon.py rename {common/templates/common => users/templates/users}/verify.html (92%) create mode 100644 users/templates/users/welcome.html diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 9d937ba0..10d17464 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -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", diff --git a/catalog/common/jsondata.py b/catalog/common/jsondata.py index 659fecd6..5d9e6793 100644 --- a/catalog/common/jsondata.py +++ b/catalog/common/jsondata.py @@ -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() diff --git a/catalog/templates/_item_user_pieces.html b/catalog/templates/_item_user_pieces.html index 7b38541a..b5c4810e 100644 --- a/catalog/templates/_item_user_pieces.html +++ b/catalog/templates/_item_user_pieces.html @@ -21,7 +21,7 @@
{% for tag in mark.tags %} - {{ tag }} + {{ tag }} {% endfor %}
diff --git a/catalog/templates/catalog_history.html b/catalog/templates/catalog_history.html index 34011bd5..7d14f137 100644 --- a/catalog/templates/catalog_history.html +++ b/catalog/templates/catalog_history.html @@ -42,7 +42,7 @@ {% if request.user.is_staff or log.actor.preference.show_last_edit %} - {{ log.actor.handler }} + {{ log.actor.username }} {% else %} {% endif %} diff --git a/compose.yml b/compose.yml index 3f7af0b9..c04f2b19 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/journal/models/common.py b/journal/models/common.py index cde18ce2..7235d1ab 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -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: diff --git a/journal/models/mark.py b/journal/models/mark.py index 1e4d3e1c..5cbde9eb 100644 --- a/journal/models/mark.py +++ b/journal/models/mark.py @@ -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): diff --git a/journal/models/note.py b/journal/models/note.py index 062f2bb8..c86de923 100644 --- a/journal/models/note.py +++ b/journal/models/note.py @@ -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 diff --git a/journal/models/review.py b/journal/models/review.py index 079722cb..75c777a3 100644 --- a/journal/models/review.py +++ b/journal/models/review.py @@ -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 diff --git a/journal/views/collection.py b/journal/views/collection.py index 82ca2e34..cd358bfa 100644 --- a/journal/views/collection.py +++ b/journal/views/collection.py @@ -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 ): diff --git a/journal/views/post.py b/journal/views/post.py index f1f12c49..aaff17f1 100644 --- a/journal/views/post.py +++ b/journal/views/post.py @@ -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}) diff --git a/journal/views/wrapped.py b/journal/views/wrapped.py index cd629c5c..2ac2ef2e 100644 --- a/journal/views/wrapped.py +++ b/journal/views/wrapped.py @@ -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", "/")) diff --git a/locale/zh_Hans/LC_MESSAGES/django.po b/locale/zh_Hans/LC_MESSAGES/django.po index e4fc8eec..eb30cce5 100644 --- a/locale/zh_Hans/LC_MESSAGES/django.po +++ b/locale/zh_Hans/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: LANGUAGE \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 original version if possible." msgstr "这是%(site_name)s的临时镜像,请尽可能使用原始站点。" @@ -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 choose an instance and register." msgstr "如果你还没有在任何联邦宇宙实例注册过,可先选择实例并注册。" -#: 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 username@instance.social or email@domain.com to confirm deletion." msgstr "输入完整的登录用 用户名@实例名电子邮件地址 以确认删除" -#: 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 @neodb@mastodon.social, only enter mastodon.social." -msgstr "请输入你的实例域名(不含@和@之前的部分);如果你的联邦账号是@neodb@mastodon.social只需要在此输入mastodon.social。" +msgstr "请输入你的实例域名(不含@和@之前的部分);如果你的联邦账号是@neodb@mastodon.social,只需要在此输入mastodon.social。" -#: 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 Fediverse (Mastodon) account yet, you may register or login with Email first, and link it with Fediverse (Mastodon) later in account settings." msgstr "如果你还没有或不便注册联邦实例账号,也可先通过电子邮件或其它平台注册登录,未来再作关联。" -#: 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 rules and terms, and use of cookies to provide necessary functionality." +#: users/templates/users/login.html:179 +msgid "Continue using this site implies consent to our rules and terms, including using cookies to provide necessary functionality." msgstr "继续访问或注册视为同意站规协议,及使用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 term of service, and feel free to contact us if you have any question or feedback.\n" -" " -msgstr "" -"\n" -"%(site_name)s还在不断完善中。 丰富的内容需要大家共同创造,试图添加垃圾数据(如添加信息混乱或缺失的书籍、以推广为主要目的的评论)将会受到严肃处理。 本站为非盈利站点,cookie和其它数据保管使用原则请参阅站内公告。 本站提供API和导出功能,请妥善备份您的数据,使用过程中遇到的问题或者错误欢迎向维护者提出。感谢理解和支持!" - -#: 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 term of service, and feel free to contact us if you have any question or feedback.\n" +" " +msgstr "" +"\n" +"%(site_name)s还在不断完善中。 丰富的内容需要大家共同创造,试图添加垃圾数据(如添加信息混乱或缺失的书籍、以推广为主要目的的评论)将会受到严肃处理。 本站为非盈利站点,cookie和其它数据保管使用原则请参阅站内公告。 本站提供API和导出功能,请妥善备份您的数据,使用过程中遇到的问题或者错误欢迎向维护者提出。感谢理解和支持!" diff --git a/locale/zh_Hant/LC_MESSAGES/django.po b/locale/zh_Hant/LC_MESSAGES/django.po index ba4ac6f7..2a613a08 100644 --- a/locale/zh_Hant/LC_MESSAGES/django.po +++ b/locale/zh_Hant/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: LANGUAGE \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 original version if possible." msgstr "這是%(site_name)s的臨時鏡像,請儘可能使用原始站點。" @@ -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 choose an instance and register." msgstr "如果你還沒有在任何聯邦宇宙實例註冊過,可先選擇實例並註冊。" -#: 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 username@instance.social or email@domain.com to confirm deletion." msgstr "輸入完整的登錄用 用戶名@實例名電子郵件地址 以確認刪除" -#: 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 @neodb@mastodon.social, only enter mastodon.social." -msgstr "請輸入你的實例域名(不含@和@之前的部分);如果你的聯邦賬號是@neodb@mastodon.social只需要在此輸入mastodon.social。" +msgstr "請輸入你的實例域名(不含@和@之前的部分);如果你的聯邦賬號是@neodb@mastodon.social,只需要在此輸入mastodon.social。" -#: 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 Fediverse (Mastodon) account yet, you may register or login with Email first, and link it with Fediverse (Mastodon) later in account settings." msgstr "如果你還沒有或不便註冊聯邦實例賬號,也可先通過電子郵件或其它平臺註冊登錄,未來再作關聯。" -#: 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 rules and terms, and use of cookies to provide necessary functionality." +#: users/templates/users/login.html:179 +msgid "Continue using this site implies consent to our rules and terms, including using cookies to provide necessary functionality." msgstr "繼續訪問或註冊視爲同意站規協議,及使用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 term of service, and feel free to contact us if you have any question or feedback.\n" -" " -msgstr "" -"\n" -"%(site_name)s還在不斷完善中。 豐富的內容需要大家共同創造,試圖添加垃圾數據(如添加信息混亂或缺失的書籍、以推廣爲主要目的的評論)將會受到嚴肅處理。 本站爲非盈利站點,cookie和其它數據保管使用原則請參閱站內公告。 本站提供API和導出功能,請妥善備份您的數據,使用過程中遇到的問題或者錯誤歡迎向維護者提出。感謝理解和支持!" - -#: 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 term of service, and feel free to contact us if you have any question or feedback.\n" +" " +msgstr "" +"\n" +"%(site_name)s還在不斷完善中。 豐富的內容需要大家共同創造,試圖添加垃圾數據(如添加信息混亂或缺失的書籍、以推廣爲主要目的的評論)將會受到嚴肅處理。 本站爲非盈利站點,cookie和其它數據保管使用原則請參閱站內公告。 本站提供API和導出功能,請妥善備份您的數據,使用過程中遇到的問題或者錯誤歡迎向維護者提出。感謝理解和支持!" diff --git a/mastodon/api.py b/mastodon/api.py index cdfc419d..e69de29b 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -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 diff --git a/mastodon/auth.py b/mastodon/auth.py index e0ed9aeb..54170205 100644 --- a/mastodon/auth.py +++ b/mastodon/auth.py @@ -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 diff --git a/mastodon/jobs.py b/mastodon/jobs.py index c29fbdd7..8be13961 100644 --- a/mastodon/jobs.py +++ b/mastodon/jobs.py @@ -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 diff --git a/mastodon/migrations/0001_initial.py b/mastodon/migrations/0001_initial.py index 5ecaf1d9..fa27d2fb 100644 --- a/mastodon/migrations/0001_initial.py +++ b/mastodon/migrations/0001_initial.py @@ -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" - ), - ), ] diff --git a/mastodon/migrations/0005_socialaccount.py b/mastodon/migrations/0005_socialaccount.py new file mode 100644 index 00000000..0a301b11 --- /dev/null +++ b/mastodon/migrations/0005_socialaccount.py @@ -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", + ), + ), + ] diff --git a/mastodon/models.py b/mastodon/models.py deleted file mode 100644 index cdb80420..00000000 --- a/mastodon/models.py +++ /dev/null @@ -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 diff --git a/mastodon/models/__init__.py b/mastodon/models/__init__.py new file mode 100644 index 00000000..caef9c75 --- /dev/null +++ b/mastodon/models/__init__.py @@ -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 diff --git a/mastodon/models/bluesky.py b/mastodon/models/bluesky.py new file mode 100644 index 00000000..094d62a7 --- /dev/null +++ b/mastodon/models/bluesky.py @@ -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 diff --git a/mastodon/models/common.py b/mastodon/models/common.py new file mode 100644 index 00000000..207f175f --- /dev/null +++ b/mastodon/models/common.py @@ -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 diff --git a/mastodon/models/email.py b/mastodon/models/email.py new file mode 100644 index 00000000..2b726a70 --- /dev/null +++ b/mastodon/models/email.py @@ -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 diff --git a/mastodon/models/mastodon.py b/mastodon/models/mastodon.py new file mode 100644 index 00000000..073a8f8d --- /dev/null +++ b/mastodon/models/mastodon.py @@ -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, + ) diff --git a/mastodon/models/threads.py b/mastodon/models/threads.py new file mode 100644 index 00000000..19d72a60 --- /dev/null +++ b/mastodon/models/threads.py @@ -0,0 +1,9 @@ +from .common import SocialAccount + + +class Threads: + pass + + +class ThreadsAccount(SocialAccount): + pass diff --git a/mastodon/utils.py b/mastodon/utils.py deleted file mode 100644 index 2e5b833c..00000000 --- a/mastodon/utils.py +++ /dev/null @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 637f64b4..409f5470 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 4dffda81..1cff2b0d 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -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 diff --git a/requirements.lock b/requirements.lock index 4d1e392b..e8050b2e 100644 --- a/requirements.lock +++ b/requirements.lock @@ -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 diff --git a/takahe/jobs.py b/takahe/jobs.py index c7c8628d..ada46ac7 100644 --- a/takahe/jobs.py +++ b/takahe/jobs.py @@ -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 diff --git a/users/account.py b/users/account.py index f421da29..9d1388ba 100644 --- a/users/account.py +++ b/users/account.py @@ -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) diff --git a/users/data.py b/users/data.py index e7b01b32..62e048fb 100644 --- a/users/data.py +++ b/users/data.py @@ -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 ) diff --git a/users/jobs/sync.py b/users/jobs/sync.py index 66c057a7..18973051 100644 --- a/users/jobs/sync.py +++ b/users/jobs/sync.py @@ -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.") diff --git a/users/management/commands/migrate_mastodon.py b/users/management/commands/migrate_mastodon.py new file mode 100644 index 00000000..c9a3d8ab --- /dev/null +++ b/users/management/commands/migrate_mastodon.py @@ -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.") diff --git a/users/models/apidentity.py b/users/models/apidentity.py index c6d15771..3eba76ff 100644 --- a/users/models/apidentity.py +++ b/users/models/apidentity.py @@ -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): diff --git a/users/models/user.py b/users/models/user.py index 4ef5d3f9..eba47a2a 100644 --- a/users/models/user.py +++ b/users/models/user.py @@ -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 ""}:{self.mastodon_acct or self.email}' + return f'USER:{self.pk}:{self.username or ""}:{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 diff --git a/users/tasks.py b/users/tasks.py index 6fa2a54c..44782c7b 100644 --- a/users/tasks.py +++ b/users/tasks.py @@ -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: diff --git a/users/templates/users/account.html b/users/templates/users/account.html index a91c6357..14f52610 100644 --- a/users/templates/users/account.html +++ b/users/templates/users/account.html @@ -27,16 +27,20 @@ {% csrf_token %} diff --git a/users/templates/users/login.html b/users/templates/users/login.html index 28ceb040..356fb766 100644 --- a/users/templates/users/login.html +++ b/users/templates/users/login.html @@ -51,11 +51,13 @@ {% trans 'back to your home page.' %} {% elif allow_any_site %}
- + {% if enable_email %} + + {% endif %} - + + {%endif%} + -->