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