From 489fe17ebbc901f67223f42a50e02b2f93eaa382 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 1 Apr 2022 03:07:56 -0400 Subject: [PATCH] login via twitter.com --- common/models.py | 1 - common/static/js/home.js | 201 ++++++++++++++++---------------- mastodon/api.py | 59 ++++++++-- mastodon/auth.py | 124 +++++++++++++------- users/models.py | 1 + users/templates/users/home.html | 8 +- users/views.py | 54 +++------ 7 files changed, 256 insertions(+), 192 deletions(-) diff --git a/common/models.py b/common/models.py index 7d21458f..0e2dbe06 100644 --- a/common/models.py +++ b/common/models.py @@ -7,7 +7,6 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db.models import Q, Count from markdownx.models import MarkdownxField from users.models import User -from mastodon.api import get_relationships, get_cross_site_id from django.utils import timezone from django.conf import settings diff --git a/common/static/js/home.js b/common/static/js/home.js index 06567c0e..6cb7e38f 100644 --- a/common/static/js/home.js +++ b/common/static/js/home.js @@ -1,5 +1,7 @@ $(document).ready( function() { + $("#userInfoCard .mast-brief").text($("
"+$("#userInfoCard .mast-brief").text().replace(/\
").text()); + $("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'
')); let token = $("#oauth2Token").text(); let mast_uri = $("#mastodonURI").text(); @@ -7,111 +9,112 @@ $(document).ready( function() { mast_domain = mast_domain.hostname; let id = $("#userMastodonID").text(); - let userInfoSpinner = $("#spinner").clone().removeAttr("hidden"); - let followersSpinner = $("#spinner").clone().removeAttr("hidden"); - let followingSpinner = $("#spinner").clone().removeAttr("hidden"); - $("#userInfoCard").append(userInfoSpinner); - $("#followings h5").after(followingSpinner); - $("#followers h5").after(followersSpinner); - $(".mast-following-more").hide(); - $(".mast-followers-more").hide(); - - getUserInfo( - id, - mast_uri, - token, - function(userData) { - let userName; - if (userData.display_name) { - userName = translateEmojis(userData.display_name, userData.emojis, true); - } else { - userName = userData.username; - } - //$("#userInfoCard .mast-acct").text(userData.acct); - $("#userInfoCard .mast-acct").attr("href", userData.url); - $("#userInfoCard .mast-avatar").attr("src", userData.avatar); - $("#userInfoCard .mast-displayname").html(userName); - $("#userInfoCard .mast-brief").text($("
"+userData.note.replace(/\
").text()); - $("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'
')); - $(userInfoSpinner).remove(); - } - ); - - getFollowers( - id, - mast_uri, - token, - function(userList, request) { - if (userList.length == 0) { - $(".mast-followers").hide(); - $(".mast-followers").before('
暂无
'); - - } else { - if (userList.length > 4){ - userList = userList.slice(0, 4); - $(".mast-followers-more").show(); + if (id && id != 'None' && mast_domain != 'twitter.com') { + let userInfoSpinner = $("#spinner").clone().removeAttr("hidden"); + let followersSpinner = $("#spinner").clone().removeAttr("hidden"); + let followingSpinner = $("#spinner").clone().removeAttr("hidden"); + // $("#userInfoCard").append(userInfoSpinner); + $("#followings h5").after(followingSpinner); + $("#followers h5").after(followersSpinner); + $(".mast-following-more").hide(); + $(".mast-followers-more").hide(); + getUserInfo( + id, + mast_uri, + token, + function(userData) { + let userName; + if (userData.display_name) { + userName = translateEmojis(userData.display_name, userData.emojis, true); + } else { + userName = userData.username; } - let template = $(".mast-followers li").clone(); - $(".mast-followers").html(""); - userList.forEach(data => { - temp = $(template).clone(); - temp.find("img").attr("src", data.avatar); - if (data.display_name) { - temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); - } else { - temp.find(".mast-displayname").text(data.username); - } - let url; - if (data.acct.includes('@')) { - url = $("#userPageURL").text().replace('0', data.acct); - } else { - url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); - } - temp.find("a").attr('href', url); - $(".mast-followers").append(temp); - }); + //$("#userInfoCard .mast-acct").text(userData.acct); + $("#userInfoCard .mast-acct").attr("href", userData.url); + $("#userInfoCard .mast-avatar").attr("src", userData.avatar); + $("#userInfoCard .mast-displayname").html(userName); + $("#userInfoCard .mast-brief").text($("
"+userData.note.replace(/\
").text()); + $("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'
')); + $(userInfoSpinner).remove(); } - $(followersSpinner).remove(); - } - ); + ); - getFollowing( - id, - mast_uri, - token, - function(userList, request) { - if (userList.length == 0) { - $(".mast-following").hide(); - $(".mast-following").before('
暂无
'); - } else { - if (userList.length > 4){ - userList = userList.slice(0, 4); - $(".mast-following-more").show(); + getFollowers( + id, + mast_uri, + token, + function(userList, request) { + if (userList.length == 0) { + $(".mast-followers").hide(); + $(".mast-followers").before('
暂无
'); + + } else { + if (userList.length > 4){ + userList = userList.slice(0, 4); + $(".mast-followers-more").show(); + } + let template = $(".mast-followers li").clone(); + $(".mast-followers").html(""); + userList.forEach(data => { + temp = $(template).clone(); + temp.find("img").attr("src", data.avatar); + if (data.display_name) { + temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); + } else { + temp.find(".mast-displayname").text(data.username); + } + let url; + if (data.acct.includes('@')) { + url = $("#userPageURL").text().replace('0', data.acct); + } else { + url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); + } + temp.find("a").attr('href', url); + $(".mast-followers").append(temp); + }); } - let template = $(".mast-following li").clone(); - $(".mast-following").html(""); - userList.forEach(data => { - temp = $(template).clone() - temp.find("img").attr("src", data.avatar); - if (data.display_name) { - temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); - } else { - temp.find(".mast-displayname").text(data.username); - } - let url; - if (data.acct.includes('@')) { - url = $("#userPageURL").text().replace('0', data.acct); - } else { - url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); - } - temp.find("a").attr('href', url); - $(".mast-following").append(temp); - }); + $(followersSpinner).remove(); } - $(followingSpinner).remove(); + ); - } - ); + getFollowing( + id, + mast_uri, + token, + function(userList, request) { + if (userList.length == 0) { + $(".mast-following").hide(); + $(".mast-following").before('
暂无
'); + } else { + if (userList.length > 4){ + userList = userList.slice(0, 4); + $(".mast-following-more").show(); + } + let template = $(".mast-following li").clone(); + $(".mast-following").html(""); + userList.forEach(data => { + temp = $(template).clone() + temp.find("img").attr("src", data.avatar); + if (data.display_name) { + temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); + } else { + temp.find(".mast-displayname").text(data.username); + } + let url; + if (data.acct.includes('@')) { + url = $("#userPageURL").text().replace('0', data.acct); + } else { + url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); + } + temp.find("a").attr('href', url); + $(".mast-following").append(temp); + }); + } + $(followingSpinner).remove(); + + } + ); + } // mobile dropdown $(".relation-dropdown__button").data("collapse", true); diff --git a/mastodon/api.py b/mastodon/api.py index 335aeec5..b976eca6 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -45,13 +45,15 @@ API_CREATE_APP = '/api/v1/apps' # GET API_SEARCH = '/api/v2/search' +TWITTER_DOMAIN = 'twitter.com' +TWITTER_API = 'api.twitter.com' get = functools.partial(requests.get, timeout=settings.MASTODON_TIMEOUT) post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT) # low level api below -def get_relationships(site, id_list, token): +def get_relationships(site, id_list, token): # no longer in use url = 'https://' + site + API_GET_RELATIONSHIPS payload = {'id[]': id_list} headers = { @@ -63,30 +65,41 @@ def get_relationships(site, id_list, token): def post_toot(site, content, visibility, token, local_only=False): - url = 'https://' + site + API_PUBLISH_TOOT headers = { 'User-Agent': 'NeoDB/1.0', 'Authorization': f'Bearer {token}', 'Idempotency-Key': random_string_generator(16) } - payload = { - 'status': content, - 'visibility': visibility, - 'local_only': True, - } - if not local_only: - del payload['local_only'] - response = post(url, headers=headers, data=payload) + if site == TWITTER_DOMAIN: + url = 'https://api.twitter.com/2/tweets' + payload = { + 'text': content if len(content) <= 150 else content[0:150] + '...' + } + response = post(url, headers=headers, json=payload) + else: + url = 'https://' + site + API_PUBLISH_TOOT + payload = { + 'status': content, + 'visibility': visibility, + } + if local_only: + payload['local_only'] = True + response = post(url, headers=headers, data=payload) + if response.status_code == 201: + response.status_code = 200 return response def get_instance_domain(domain_name): + if domain_name.lower().strip() == TWITTER_DOMAIN: + return TWITTER_DOMAIN try: response = get(f'https://{domain_name}/api/v1/instance', headers={'User-Agent': 'NeoDB/1.0'}) return response.json()['uri'].lower().split('//')[-1].split('/')[0] except: return domain_name + def create_app(domain_name): # naive protocal strip is_http = False @@ -154,6 +167,8 @@ def get_cross_site_id(target_user, target_site, token): """ if target_site == target_user.mastodon_site: return target_user.mastodon_id + if target_site == TWITTER_DOMAIN: + return None try: cross_site_info = CrossSiteUserInfo.objects.get( @@ -182,14 +197,36 @@ def random_string_generator(n): def verify_account(site, token): + if site == TWITTER_DOMAIN: + url = 'https://' + TWITTER_API + '/2/users/me?user.fields=id,username,name,description,profile_image_url,created_at,protected' + try: + response = get(url, headers={'User-Agent': 'NeoDB/1.0', 'Authorization': f'Bearer {token}'}) + if response.status_code != 200: + print(url) + print(response.status_code) + print(response.text) + return response.status_code, None + r = response.json()['data'] + r['display_name'] = r['name'] + r['note'] = r['description'] + r['avatar'] = r['profile_image_url'] + r['avatar_static'] = r['profile_image_url'] + r['locked'] = r['protected'] + r['url'] = f'https://{TWITTER_DOMAIN}/{r["username"]}' + return 200, r + except Exception: + return -1, None url = 'https://' + site + API_VERIFY_ACCOUNT try: response = get(url, headers={'User-Agent': 'NeoDB/1.0', 'Authorization': f'Bearer {token}'}) - return response.status_code, response.json() if response.status_code == 200 else None + 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): + if site == TWITTER_DOMAIN: + return [] url = 'https://' + site + api results = [] while url: diff --git a/mastodon/auth.py b/mastodon/auth.py index 25fc28b7..697acd65 100644 --- a/mastodon/auth.py +++ b/mastodon/auth.py @@ -3,74 +3,115 @@ from django.shortcuts import reverse from .api import * from .models import MastodonApplication from django.conf import settings +from urllib.parse import quote + + +def get_mastodon_application(domain): + app = MastodonApplication.objects.filter(domain_name=domain).first() + if app is not None: + return app, '' + if domain == TWITTER_DOMAIN: + return None, 'Twitter未配置' + error_msg = None + try: + response = create_app(domain) + except (requests.exceptions.Timeout, ConnectionError): + error_msg = _("联邦网络请求超时。") + except Exception as e: + error_msg = str(e) + else: + # fill the form with returned data + if response.status_code != 200: + error_msg = "实例连接错误,代码: " + str(response.status_code) + print(f'Error connecting {domain}: {response.status_code} {response.content.decode("utf-8")}') + else: + try: + data = response.json() + except Exception as e: + error_msg = "实例返回内容无法识别" + print(f'Error connecting {domain}: {response.status_code} {response.content.decode("utf-8")} {e}') + else: + app = MastodonApplication.objects.create(domain_name=domain, app_id=data['id'], client_id=data['client_id'], + client_secret=data['client_secret'], vapid_key=data['vapid_key'] if 'vapid_key' in data else '') + return app, error_msg + + +def get_mastodon_login_url(app, login_domain, request): + url = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') + if login_domain == TWITTER_DOMAIN: + return f"https://twitter.com/i/oauth2/authorize?response_type=code&client_id={app.client_id}&redirect_uri={quote(url)}&scope={quote(settings.TWITTER_CLIENT_SCOPE)}&state=state&code_challenge=challenge&code_challenge_method=plain" + return "https://" + login_domain + "/oauth/authorize?client_id=" + app.client_id + "&scope=" + quote(settings.MASTODON_CLIENT_SCOPE) + "&redirect_uri=" + url + "&response_type=code" def obtain_token(site, request, code): """ Returns token if success else None. """ mast_app = MastodonApplication.objects.get(domain_name=site) + redirect_uri = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') payload = { 'client_id': mast_app.client_id, 'client_secret': mast_app.client_secret, - 'redirect_uri': f"https://{request.get_host()}{reverse('users:OAuth2_login')}", + 'redirect_uri': redirect_uri, 'grant_type': 'authorization_code', 'code': code, - 'scope': 'read write' + 'code_verifier': 'challenge' } - if settings.DEBUG: - payload['redirect_uri'] = f"http://{request.get_host()}{reverse('users:OAuth2_login')}", + headers = {'User-Agent': 'NeoDB/1.0'} + auth = None if mast_app.is_proxy: url = 'https://' + mast_app.proxy_to + API_OBTAIN_TOKEN + elif site == TWITTER_DOMAIN: + url = 'https://api.twitter.com/2/oauth2/token' + auth = (mast_app.client_id, mast_app.client_secret) + del payload['client_secret'] else: url = 'https://' + mast_app.domain_name + API_OBTAIN_TOKEN - response = post(url, data=payload, headers={'User-Agent': 'NeoDB/1.0'}) + response = post(url, data=payload, headers=headers, auth=auth) + # {"token_type":"bearer","expires_in":7200,"access_token":"VGpkOEZGR3FQRDJ5NkZ0dmYyYWIwS0dqeHpvTnk4eXp0NV9nWDJ2TEpmM1ZTOjE2NDg3ODMxNTU4Mzc6MToxOmF0OjE","scope":"block.read follows.read offline.access tweet.write users.read mute.read","refresh_token":"b1pXbGEzeUF1WE5yZHJOWmxTeWpvMTBrQmZPd0czLU0tQndZQTUyU3FwRDVIOjE2NDg3ODMxNTU4Mzg6MToxOnJ0OjE"} if response.status_code != 200: - return + print(url) + print(response.status_code) + print(response.text) + return None, None + data = response.json() + return data.get('access_token'), data.get('refresh_token', '') + + +def refresh_access_token(site, refresh_token): + if site != TWITTER_DOMAIN: + return None + mast_app = MastodonApplication.objects.get(domain_name=site) + url = 'https://api.twitter.com/2/oauth2/token' + payload = { + 'client_id': mast_app.client_id, + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + } + headers = {'User-Agent': 'NeoDB/1.0'} + auth = (mast_app.client_id, mast_app.client_secret) + response = post(url, data=payload, headers=headers, auth=auth) + if response.status_code != 200: + print(url) + print(response.status_code) + print(response.text) + return None data = response.json() return data.get('access_token') -def get_user_data(site, token): - url = 'https://' + site + API_VERIFY_ACCOUNT - headers = { - 'User-Agent': 'NeoDB/1.0', - 'Authorization': f'Bearer {token}' - } - response = get(url, headers=headers) - if response.status_code != 200: - return None - return response.json() - - 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, - 'scope': token + 'token': token } if mast_app.is_proxy: url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN else: url = 'https://' + site + API_REVOKE_TOKEN - response = post(url, data=payload, headers={'User-Agent': 'NeoDB/1.0'}) - - -def verify_token(site, token): - """ Check if the token is valid and is of local instance. """ - url = 'https://' + site + API_VERIFY_ACCOUNT - headers = { - 'User-Agent': 'NeoDB/1.0', - 'Authorization': f'Bearer {token}' - } - response = get(url, headers=headers) - if response.status_code == 200: - res_data = response.json() - # check if is local instance user - if res_data['acct'] == res_data['username']: - return True - return False + post(url, data=payload, headers={'User-Agent': 'NeoDB/1.0'}) class OAuth2Backend(ModelBackend): @@ -78,22 +119,23 @@ class OAuth2Backend(ModelBackend): # "authenticate() should check the credentials it gets and returns # a user object that matches those credentials." # arg request is an interface specification, not used in this implementation - def authenticate(self, request, token=None, username=None, site=None, **kwargs): + + def authenticate(self, request, token=None, username=None, site=None, **kwargs): """ when username is provided, assume that token is newly obtained and valid """ if token is None or site is None: return if username is None: - user_data = get_user_data(site, token) - if user_data: - username = user_data['username'] + code, user_data = verify_account(site, token) + if code == 200: + userid = user_data['id'] else: # aquiring user data fail means token is invalid thus auth fail return None # when username is provided, assume that token is newly obtained and valid try: - user = UserModel._default_manager.get(username=username, mastodon_site=site) + user = UserModel._default_manager.get(mastodon_id=userid, mastodon_site=site) except UserModel.DoesNotExist: return None else: diff --git a/users/models.py b/users/models.py index 23842abd..a34e6e51 100644 --- a/users/models.py +++ b/users/models.py @@ -26,6 +26,7 @@ class User(AbstractUser): # mastodon domain name, eg donotban.com mastodon_site = models.CharField(max_length=100, blank=False) mastodon_token = models.CharField(max_length=100, default='') + mastodon_refresh_token = models.CharField(max_length=100, default='') mastodon_locked = models.BooleanField(default=False) mastodon_followers = models.JSONField(default=list) mastodon_following = models.JSONField(default=list) diff --git a/users/templates/users/home.html b/users/templates/users/home.html index 18ba2e88..f89bc3a4 100644 --- a/users/templates/users/home.html +++ b/users/templates/users/home.html @@ -639,13 +639,13 @@