fetch remote identity

This commit is contained in:
Your Name 2023-08-13 18:00:10 -04:00 committed by Henri Dickson
parent 31ba886210
commit c1ef7b3892
26 changed files with 328 additions and 102 deletions

View file

@ -1,13 +1,11 @@
import hashlib
import logging
import uuid
import re
import django_rq
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.core.exceptions import BadRequest
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from rq.job import Job
@ -15,7 +13,8 @@ from rq.job import Job
from catalog.common.models import ItemCategory, SiteName
from catalog.common.sites import AbstractSite, SiteManager
from common.config import PAGE_LINK_NUMBER
from common.utils import PageLinksGenerator
from common.utils import HTTPResponseHXRedirect, PageLinksGenerator
from users.views import query_identity
from ..models import *
from .external import ExternalSources
@ -24,16 +23,7 @@ from .models import enqueue_fetch, get_fetch_lock, query_index
_logger = logging.getLogger(__name__)
class HTTPResponseHXRedirect(HttpResponseRedirect):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self["HX-Redirect"] = self["Location"]
status_code = 200
def fetch_refresh(request, job_id):
retry = request.GET
try:
job = Job.fetch(id=job_id, connection=django_rq.get_connection("fetch"))
item_url = job.return_value()
@ -102,6 +92,9 @@ def visible_categories(request):
def search(request):
keywords = request.GET.get("q", default="").strip()
if re.match(r"^[@]", keywords):
return query_identity(request, keywords.replace("", "@"))
category = request.GET.get("c", default="all").strip().lower()
hide_category = False
if category == "all" or not category:
@ -115,7 +108,6 @@ def search(request):
hide_category = True
except:
categories = visible_categories(request)
keywords = request.GET.get("q", default="").strip()
tag = request.GET.get("tag", default="").strip()
p = request.GET.get("page", default="1")
p = int(p) if p.isdigit() else 1

View file

@ -312,6 +312,7 @@ def discover(request):
"discover.html",
{
"user": user,
"identity": user.identity,
"gallery_list": gallery_list,
"recent_podcast_episodes": recent_podcast_episodes,
"books_in_progress": books_in_progress,

View file

@ -15,7 +15,7 @@
<input type="search"
name="q"
id="q"
placeholder="标题、创作者、ISBN、站外条目链接"
placeholder="标题、创作者、ISBN、站外条目链接、@用户名、@用户名@实例"
class="search"
value="{{ request.GET.q|default:'' }}" />
<select name="c">

View file

@ -38,29 +38,33 @@
<summary>
<div>
<div class="avatar">
<a href="{{ user.url }}" onclick="window.location = this.href;">
<a href="{{ identity.url }}" onclick="window.location = this.href;">
{% comment %} onclick to workaround webkit issue with <a /> in <summary /> {% endcomment %}
<img src="{{ user.avatar }}" alt="">
<img src="{{ identity.avatar }}" alt="">
</a>
</div>
<div>
<hgroup>
<h6 class="nickname">{{ user.display_name }}</h6>
<h6 class="nickname">{{ identity.display_name }}</h6>
<div>
<a href="{{ user.mastodon_account.url }}"
target="_blank"
rel="noopener"
onclick="window.open(this.href); return false;">
<span class="handler">{{ user.handler }}</span>
</a>
<span class="handler">{{ identity.full_handle }}</span>
{% if identity.user and identity.user.mastodon_account %}
<a href="{{ identity.user.mastodon_account.url }}"
target="_blank"
rel="noopener"
onclick="window.open(this.href); return false;"
title="@{{ user.mastodon_acct }}">
<i class="fa-solid fa-circle-nodes"></i>
</a>
{% endif %}
</div>
</hgroup>
</div>
</div>
</summary>
<span class="action">
{% if user == request.user %}
{% if user.locked %}
{% if identity.user == request.user %}
{% if identity.locked %}
<span>
<a title="你已开启关注审核">
<i class="fa-solid fa-user-shield"></i>
@ -71,7 +75,7 @@
{% include 'users/profile_actions.html' %}
{% endif %}
</span>
<p>{{ user.mastodon_account.note|bleach:"a,p,span,br" }}</p>
<p>{{ identity.summary|bleach:"a,p,span,br" }}</p>
</details>
</article>
</section>
@ -79,9 +83,9 @@
{% if show_progress %}
<section>
<article>
<details {% if user.featured_collections.all %}open{% endif %}>
<details {% if identity.featured_collections.all %}open{% endif %}>
<summary>{% trans '当前目标' %}</summary>
{% for featured_collection in user.featured_collections.all %}
{% for featured_collection in identity.featured_collections.all %}
{% user_visibility_of featured_collection as visible %}
{% if visible %}
{% user_stats_of collection=featured_collection user=user as stats %}
@ -190,7 +194,7 @@
<div class="tag-list">
{% for t in top_tags %}
<span>
<a href="{% url 'journal:user_tag_member_list' user.handler t %}">{{ t }}</a>
<a href="{% url 'journal:user_tag_member_list' identity.handler t %}">{{ t }}</a>
</span>
{% empty %}
<div class="empty">暂无可见标签</div>
@ -198,7 +202,7 @@
</div>
<small>
{% if top_tags %}
<a href="{% url 'journal:user_tag_list' user.handler %}">...{% trans '全部' %}</a>
<a href="{% url 'journal:user_tag_list' identity.handler %}">...{% trans '全部' %}</a>
{% endif %}
</small>
</details>

View file

@ -15,8 +15,12 @@ def mastodon(domain):
@register.simple_tag(takes_context=True)
def current_user_relationship(context, user: "User"):
current_user = context["request"].user
def current_user_relationship(context, target_identity: "APIdentity"):
current_identity = (
context["request"].user.identity
if context["request"].user.is_authenticated
else None
)
r = {
"requesting": False,
"following": False,
@ -26,9 +30,7 @@ def current_user_relationship(context, user: "User"):
"rejecting": False,
"status": "",
}
if current_user and current_user.is_authenticated and current_user != user:
current_identity = context["request"].user.identity
target_identity = user.identity
if current_identity and current_identity != target_identity:
if current_identity.is_blocking(
target_identity
) or current_identity.is_blocked_by(target_identity):

View file

@ -1,7 +1,7 @@
import uuid
from typing import TYPE_CHECKING
from django.http import Http404, HttpRequest
from django.http import Http404, HttpRequest, HttpResponseRedirect
from django.utils import timezone
from django.utils.baseconv import base62
@ -18,6 +18,14 @@ class AuthedHttpRequest(HttpRequest):
target_identity: "APIdentity"
class HTTPResponseHXRedirect(HttpResponseRedirect):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self["HX-Redirect"] = self["Location"]
status_code = 200
class PageLinksGenerator:
# TODO inherit django paginator
"""

View file

@ -19,26 +19,24 @@ class Like(Piece): # TODO remove
@staticmethod
def user_liked_piece(owner, piece):
return Like.objects.filter(owner=owner.identity, target=piece).exists()
return Like.objects.filter(owner=owner, target=piece).exists()
@staticmethod
def user_like_piece(owner, piece):
if not piece:
return
like = Like.objects.filter(owner=owner.identity, target=piece).first()
like = Like.objects.filter(owner=owner, target=piece).first()
if not like:
like = Like.objects.create(owner=owner.identity, target=piece)
like = Like.objects.create(owner=owner, target=piece)
return like
@staticmethod
def user_unlike_piece(owner, piece):
if not piece:
return
Like.objects.filter(owner=owner.identity, target=piece).delete()
Like.objects.filter(owner=owner, target=piece).delete()
@staticmethod
def user_likes_by_class(owner, cls):
ctype_id = ContentType.objects.get_for_model(cls)
return Like.objects.filter(
owner=owner.identity, target__polymorphic_ctype=ctype_id
)
return Like.objects.filter(owner=owner, target__polymorphic_ctype=ctype_id)

View file

@ -10,19 +10,20 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if user == request.user %}
{% if identity.user == request.user %}
<title>{{ site_name }} - {% trans '我的个人主页' %}</title>
{% else %}
<title>{{ site_name }} - {{ user.display_name }}</title>
<title>{{ site_name }} - {{ identity.display_name }}</title>
{% endif %}
<meta property="og:title" content="{{ site_name }}用户 - {{ user.handler }}">
<meta property="og:title"
content="{{ identity.handler }} - {{ site_name }}">
<meta property="og:url" content="{{ request.build_absolute_uri }}">
<meta property="og:image" content="{{ user.avatar }}">
<meta property="og:image" content="{{ identity.avatar }}">
<meta property="og:site_name" content="{{ site_name }}">
{% if user.preference.no_anonymous_view %}<meta name="robots" content="noindex">{% endif %}
{% if identity.preference.no_anonymous_view %}<meta name="robots" content="noindex">{% endif %}
<link rel="alternate"
type="application/rss+xml"
title="{{ site_name }} - {{ user.handler }}的评论"
title="{{ site_name }} - {{ identity.handler }}的评论"
href="{{ request.build_absolute_uri }}feed/reviews/">
{% include "common_libs.html" with jquery=0 v2=1 %}
<script src="{% static 'js/calendar_yearview_blocks.js' %}" defer></script>
@ -31,7 +32,7 @@
rel="stylesheet" />
</head>
<body>
{% if request.user == user %}
{% if request.user == identity.user %}
{% include "_header.html" with current="home" %}
{% else %}
{% include "_header.html" %}
@ -48,7 +49,7 @@
</p>
</div>
<span class="calendar_data"
hx-get="{% url 'journal:user_calendar_data' user.handler %}"
hx-get="{% url 'journal:user_calendar_data' identity.handler %}"
hx-trigger="load"
hx-swap="innerHTML"
style="display:none"></span>
@ -62,7 +63,7 @@
<h5>
{{ shelf.title }}
<small>
<a href="{% if shelf_type == 'reviewed' %}{% url 'journal:user_review_list' user.handler category %}{% else %}{% url 'journal:user_mark_list' user.handler shelf_type category %}{% endif %}">{{ shelf.count }}</a>
<a href="{% if shelf_type == 'reviewed' %}{% url 'journal:user_review_list' identity.handler category %}{% else %}{% url 'journal:user_mark_list' identity.handler shelf_type category %}{% endif %}">{{ shelf.count }}</a>
</small>
</h5>
<ul class="cards">
@ -89,8 +90,8 @@
<h5>
{% trans '创建的收藏单' %}
<small>
<a href="{% url 'journal:user_collection_list' user.handler %}">{{ collections_count }}</a>
{% if user == request.user %}
<a href="{% url 'journal:user_collection_list' identity.handler %}">{{ collections_count }}</a>
{% if identity.user == request.user %}
<a href="{% url 'journal:collection_create' %}">
<i class="fa-regular fa-square-plus"></i>
</a>
@ -117,7 +118,7 @@
<h5>
{% trans '关注的收藏单' %}
<small>
<a href="{% url 'journal:user_liked_collection_list' user.handler %}">{{ liked_collections_count }}</a>
<a href="{% url 'journal:user_liked_collection_list' identity.handler %}">{{ liked_collections_count }}</a>
</small>
</h5>
<ul class="cards">
@ -186,6 +187,10 @@
{% include "_sidebar.html" with show_progress=1 show_profile=1 %}
</main>
{% include "_footer.html" %}
<a href="{{ user.mastodon_account.url }}" rel="me" style="display:none">Mastodon verification</a>
{% if identity.user and identity.user.mastodon_account %}
<a href="{{ identity.user.mastodon_account.url }}"
rel="me"
style="display:none">Mastodon verification</a>
{% endif %}
</body>
</html>

View file

@ -266,6 +266,7 @@ def collection_edit(request: AuthedHttpRequest, collection_uuid=None):
"form": form,
"collection": collection,
"user": collection.owner.user if collection else request.user,
"identity": collection.owner if collection else request.user.identity,
},
)
elif request.method == "POST":
@ -300,6 +301,7 @@ def user_collection_list(request: AuthedHttpRequest, user_name):
"user_collection_list.html",
{
"user": target.user,
"identity": target,
"collections": collections,
},
)
@ -317,6 +319,7 @@ def user_liked_collection_list(request: AuthedHttpRequest, user_name):
"user_collection_list.html",
{
"user": target.user,
"identity": target,
"collections": collections,
"liked": True,
},

View file

@ -20,11 +20,9 @@ PAGE_SIZE = 10
def target_identity_required(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
request = kwargs["request"]
handler = kwargs["user_name"]
def wrapper(request, user_name, *args, **kwargs):
try:
target = APIdentity.get_by_handler(handler)
target = APIdentity.get_by_handler(user_name)
except:
return render_user_not_found(request)
if not target.is_visible_to_user(request.user):
@ -33,6 +31,7 @@ def target_identity_required(func):
# request.identity = (
# request.user.identity if request.user.is_authenticated else None
# )
return func(request, user_name, *args, **kwargs)
return wrapper
@ -100,7 +99,13 @@ def render_list(
return render(
request,
f"user_{type}_list.html",
{"user": target.user, "members": members, "tag": tag, "pagination": pagination},
{
"user": target.user,
"identity": target,
"members": members,
"tag": tag,
"pagination": pagination,
},
)

View file

@ -81,6 +81,7 @@ def profile(request: AuthedHttpRequest, user_name):
"profile.html",
{
"user": target.user,
"identity": target,
"top_tags": top_tags,
"shelf_list": shelf_list,
"collections": collections[:10],
@ -96,13 +97,14 @@ def profile(request: AuthedHttpRequest, user_name):
def user_calendar_data(request, user_name):
if request.method != "GET":
if request.method != "GET" or not request.user.is_authenticated:
raise BadRequest()
user = User.get(user_name)
if user is None or not request.user.is_authenticated:
return HttpResponse("")
max_visiblity = max_visiblity_to_user(request.user, user.identity)
calendar_data = user.shelf_manager.get_calendar_data(max_visiblity)
try:
target = APIdentity.get_by_handler(user_name)
except:
return HttpResponse("unavailable")
max_visiblity = max_visiblity_to_user(request.user, target)
calendar_data = target.shelf_manager.get_calendar_data(max_visiblity)
return render(
request,
"calendar_data.html",

View file

@ -31,6 +31,7 @@ def user_tag_list(request, user_name):
"user_tag_list.html",
{
"user": target.user,
"identity": target,
"tags": tags,
},
)

View file

@ -66,7 +66,7 @@ def _get_visibility(post_visibility):
def _update_or_create_post(pk, obj):
post = Post.objects.get(pk=pk)
owner = Takahe.get_or_create_apidentity(post.author)
owner = Takahe.get_or_create_remote_apidentity(post.author)
if not post.type_data:
logger.warning(f"Post {post} has no type_data")
return

View file

@ -486,4 +486,24 @@ class Migration(migrations.Migration):
"unique_together": {("source", "target")},
},
),
migrations.CreateModel(
name="InboxMessage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("message", models.JSONField()),
("state", models.CharField(default="received", max_length=100)),
("state_changed", models.DateTimeField(auto_now_add=True)),
],
options={
"db_table": "users_inboxmessage",
},
),
]

View file

@ -1393,3 +1393,34 @@ class Block(models.Model):
block.uri = source.actor_uri + f"block/{block.pk}/"
block.save()
return block
class InboxMessage(models.Model):
"""
an incoming inbox message that needs processing.
Yes, this is kind of its own message queue built on the state graph system.
It's fine. It'll scale up to a decent point.
"""
message = models.JSONField()
# state = StateField(InboxMessageStates)
state = models.CharField(max_length=100, default="received")
state_changed = models.DateTimeField(auto_now_add=True)
class Meta:
# managed = False
db_table = "users_inboxmessage"
@classmethod
def create_internal(cls, payload):
"""
Creates an internal action message
"""
cls.objects.create(
message={
"type": "__internal__",
"object": payload,
}
)

View file

@ -89,6 +89,7 @@ class Takahe:
logger.info(f"Creating takahe identity {u}@{domain}")
identity = Identity.objects.create(
actor_uri=f"https://{domain.uri_domain}/@{u.username}@{domain.domain}/",
profile_uri=u.url,
username=u.username,
domain=domain,
name=u.username,
@ -121,6 +122,16 @@ class Takahe:
u.save(update_fields=["identity"])
return apidentity
@staticmethod
def get_identity_by_handler(username: str, domain: str) -> Identity | None:
return Identity.objects.filter(
username__iexact=username, domain__domain__iexact=domain
).first()
@staticmethod
def fetch_remote_identity(handler: str) -> int | None:
InboxMessage.create_internal({"type": "FetchIdentity", "handle": handler})
@staticmethod
def get_identity(pk: int):
return Identity.objects.get(pk=pk)
@ -134,20 +145,21 @@ class Takahe:
)
@staticmethod
def get_or_create_apidentity(identity: Identity):
def get_or_create_remote_apidentity(identity: Identity):
from users.models import APIdentity
apid = APIdentity.objects.filter(pk=identity.pk).first()
if not apid:
if identity.local:
raise ValueError(f"local takahe identity {identity} missing APIdentity")
if not identity.domain:
if not identity.domain_id:
raise ValueError(f"remote takahe identity {identity} missing domain")
apid = APIdentity.objects.create(
id=identity.pk,
user=None,
local=False,
username=identity.username,
domain_name=identity.domain.domain,
domain_name=identity.domain_id,
deleted=identity.deleted,
)
return apid

View file

@ -41,7 +41,8 @@ class Migration(migrations.Migration):
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="identity",
to=settings.AUTH_USER_MODEL,
),

View file

@ -56,6 +56,7 @@ def init_identity(apps, schema_editor):
takahe_identity = TakaheIdentity.objects.create(
pk=user.pk,
actor_uri=f"https://{service_domain or domain}/@{username}@{domain}/",
profile_uri=user.url,
username=username,
domain=tdomain,
name=username,
@ -66,7 +67,6 @@ def init_identity(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("users", "0012_apidentity"),
]

View file

@ -2,10 +2,11 @@ from functools import cached_property
from django.conf import settings
from django.db import models
from loguru import logger
from django.templatetags.static import static
from takahe.utils import Takahe
from .preference import Preference
from .user import User
@ -16,7 +17,9 @@ class APIdentity(models.Model):
This model is used as 1:1 mapping to Takahe Identity Model
"""
user = models.OneToOneField("User", models.CASCADE, related_name="identity")
user = models.OneToOneField(
"User", models.SET_NULL, related_name="identity", null=True
)
local = models.BooleanField()
username = models.CharField(max_length=500, blank=True, null=True)
domain_name = models.CharField(max_length=500, blank=True, null=True)
@ -28,6 +31,9 @@ class APIdentity(models.Model):
models.Index(fields=["domain_name", "username"]),
]
def __str__(self):
return f"{self.pk}:{self.username}@{self.domain_name}"
@cached_property
def takahe_identity(self):
return Takahe.get_identity(self.pk)
@ -44,6 +50,10 @@ class APIdentity(models.Model):
def discoverable(self):
return self.takahe_identity.discoverable
@property
def locked(self):
return self.takahe_identity.manually_approves_followers
@property
def actor_uri(self):
return self.takahe_identity.actor_uri
@ -54,11 +64,15 @@ class APIdentity(models.Model):
@property
def display_name(self):
return self.takahe_identity.name
return self.takahe_identity.name or self.username
@property
def summary(self):
return self.takahe_identity.summary or ""
@property
def avatar(self):
return self.user.avatar # FiXME
return self.takahe_identity.icon_uri or static("img/avatar.svg") # fixme
@property
def url(self):
@ -66,14 +80,18 @@ class APIdentity(models.Model):
@property
def preference(self):
return self.user.preference
return self.user.preference if self.user else Preference()
@property
def full_handle(self):
return f"@{self.username}@{self.domain_name}"
@property
def handler(self):
if self.local:
return self.username
else:
return f"{self.username}@{self.domain_name}"
return f"@{self.username}@{self.domain_name}"
@property
def following(self):
@ -157,21 +175,40 @@ class APIdentity(models.Model):
@classmethod
def get_by_handler(cls, handler: str) -> "APIdentity":
"""
Handler format
'id' - local identity with username 'id'
'id@site' - local identity with linked mastodon id == 'id@site'
'@id' - local identity with username 'id'
'@id@site' - remote activitypub identity 'id@site'
"""
s = handler.split("@")
if len(s) == 1:
return cls.objects.get(username=s[0], local=True, deleted__isnull=True)
elif len(s) == 2:
l = len(s)
if l == 1 or (l == 2 and s[0] == ""):
return cls.objects.get(
user__mastodon_username=s[0],
user__mastodon_site=s[1],
username__iexact=s[0] if l == 1 else s[1],
local=True,
deleted__isnull=True,
)
elif len(s) == 3 and s[0] == "":
elif l == 2:
return cls.objects.get(
username=s[0], domain_name=s[1], local=False, deleted__isnull=True
user__mastodon_username__iexact=s[0],
user__mastodon_site__iexact=s[1],
deleted__isnull=True,
)
elif l == 3 and s[0] == "":
i = cls.objects.filter(
username__iexact=s[1], domain_name__iexact=s[2], deleted__isnull=True
).first()
if i:
return i
if s[2].lower() != settings.SITE_INFO["site_domain"].lower():
identity = Takahe.get_identity_by_handler(s[1], s[2])
if identity:
return Takahe.get_or_create_remote_apidentity(identity)
raise cls.DoesNotExist(f"Identity not exist {handler}")
else:
raise cls.DoesNotExist(f"Invalid handler {handler}")
raise cls.DoesNotExist(f"Identity handler invalid {handler}")
@cached_property
def activity_manager(self):

View file

@ -0,0 +1,4 @@
<p>
<i class="fa-solid fa-triangle-exclamation"></i>
无法找到用户,请确认拼写正确;也可能服务器正忙,请稍后再尝试。
</p>

View file

@ -0,0 +1,42 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load humanize %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% load highlight %}
{% load thumb %}
<!DOCTYPE html>
<html lang="zh" class="classic-page">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} - {% trans '查询用户' %}</title>
{% include "common_libs.html" with jquery=0 v2=1 %}
</head>
<body>
{% include '_header.html' %}
<main>
<div class="grid__main">
<article>
{% if handle %}
<h5>正在从联邦网络查询{{ handle }}</h5>
<div hx-get="{% url 'users:fetch_refresh' %}?handle={{ handle }}"
hx-trigger="load delay:2s"
hx-swap="outerHTML">
<i class="fa-solid fa-compact-disc fa-spin loading"></i>
</div>
{% else %}
<h5>获取系统繁忙,请稍等几秒钟再搜索</h5>
{% endif %}
</article>
</div>
<aside class="grid__aside bottom">
{% include "_sidebar_search.html" %}
</aside>
</main>
{% include '_footer.html' %}
</body>
</html>

View file

@ -0,0 +1,5 @@
<div hx-get="{% url 'users:fetch_refresh' %}?handle={{ handle }}&retry={{ retry }}"
hx-trigger="load delay:{{ delay }}s"
hx-swap="outerHTML">
<i class="fa-solid fa-compact-disc fa-spin loading"></i>
</div>

View file

@ -1,10 +1,10 @@
{% load mastodon %}
{% current_user_relationship user as relationship %}
{% current_user_relationship identity as relationship %}
{% if relationship.rejecting %}
<span class="tag-list">
<span><a title="点击可取消屏蔽"
hx-confirm="确定要取消对该用户的屏蔽吗?"
hx-post="{% url 'users:unblock' user.handler %}"
hx-post="{% url 'users:unblock' identity.handler %}"
hx-target="closest .action"
hx-swap="innerHTML">已屏蔽</a></span>
</span>
@ -18,7 +18,7 @@
{% endif %}
{% if show_home %}
<span>
<a title="用户主页" href="{{ user.url }}">
<a title="用户主页" href="{{ identity.url }}">
<i class="fa-solid fa-home"></i>
</a>
</span>
@ -28,7 +28,7 @@
<span>
<a title="已关注,点击可取消关注"
class="activated"
hx-post="{% url 'users:unfollow' user.handler %}"
hx-post="{% url 'users:unfollow' identity.handler %}"
hx-target="closest .action"
hx-swap="innerHTML">
<i class="fa-solid fa-user-check"></i>
@ -38,10 +38,10 @@
<span><a title="已在联邦宇宙关注该用户" class="activated"><i class="fa-solid fa-circle-check"></i></a></span>
{% endif %}
{% else %}
{% if user.locked %}
{% if identity.locked %}
<span>
<a title="用户已开启关注审核"
hx-post="{% url 'users:locked' user.handler %}"
hx-post="{% url 'users:locked' identity.handler %}"
hx-target="body"
hx-swap="beforeend">
<i class="fa-solid fa-user-shield"></i>
@ -50,7 +50,7 @@
{% else %}
<span>
<a title="点击可关注该用户"
hx-post="{% url 'users:follow' user.handler %}"
hx-post="{% url 'users:follow' identity.handler %}"
hx-target="closest .action"
hx-swap="innerHTML">
<i class="fa-solid fa-user-plus"></i>
@ -61,7 +61,7 @@
{% if not relationship.muting %}
<span>
<a title="点击可隐藏该用户"
hx-post="{% url 'users:mute' user.handler %}"
hx-post="{% url 'users:mute' identity.handler %}"
hx-target="closest .action"
hx-swap="innerHTML">
<i class="fa-solid fa-volume-high"></i>
@ -71,7 +71,7 @@
<span>
<a title="已隐藏,点击可取消隐藏"
class="activated"
hx-post="{% url 'users:unmute' user.handler %}"
hx-post="{% url 'users:unmute' identity.handler %}"
hx-target="closest .action"
hx-swap="innerHTML">
<i class="fa-solid fa-volume-off"></i>
@ -87,11 +87,11 @@
<span>
<a title="点击可屏蔽该用户"
hx-confirm="确定要屏蔽该用户吗?"
hx-post="{% url 'users:block' user.handler %}"
hx-post="{% url 'users:block' identity.handler %}"
hx-target="closest .action"
hx-swap="innerHTML">
<i class="fa-solid fa-user-slash"></i>
</a>
</span>
{% comment %} <span><a href="{% url 'users:report' %}?user_id={{ user.id }}">{% trans '投诉用户' %}</a></span> {% endcomment %}
{% comment %} <span><a href="{% url 'users:report' %}?user_id={{ identity.id }}">{% trans '投诉用户' %}</a></span> {% endcomment %}
{% endif %}

View file

@ -1,3 +1,4 @@
from django.conf import settings
from django.test import TestCase
from takahe.utils import Takahe
@ -15,6 +16,24 @@ class UserTest(TestCase):
self.bob = User.register(
mastodon_site="KKCity", mastodon_username="Bob", username="bob"
).identity
self.domain = settings.SITE_INFO.get("site_domain")
def test_handle(self):
self.assertEqual(APIdentity.get_by_handler("Alice"), self.alice)
self.assertEqual(APIdentity.get_by_handler("@alice"), self.alice)
self.assertEqual(APIdentity.get_by_handler("Alice@MySpace"), self.alice)
self.assertEqual(APIdentity.get_by_handler("alice@myspace"), self.alice)
self.assertEqual(APIdentity.get_by_handler("@alice@" + self.domain), self.alice)
self.assertEqual(APIdentity.get_by_handler("@Alice@" + self.domain), self.alice)
self.assertRaises(
APIdentity.DoesNotExist, APIdentity.get_by_handler, "@Alice@MySpace"
)
self.assertRaises(
APIdentity.DoesNotExist, APIdentity.get_by_handler, "@alice@KKCity"
)
def test_fetch(self):
pass
def test_follow(self):
self.alice.follow(self.bob)

View file

@ -12,6 +12,7 @@ urlpatterns = [
path("register", register, name="register"),
path("connect", connect, name="connect"),
path("reconnect", reconnect, name="reconnect"),
path("fetch_refresh", fetch_refresh, name="fetch_refresh"),
path("data", data, name="data"),
path("info", account_info, name="info"),
path("data/import/status", data_import_status, name="import_status"),

View file

@ -9,18 +9,20 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from common.config import *
from common.utils import HTTPResponseHXRedirect
from management.models import Announcement
from mastodon.api import *
from takahe.utils import Takahe
from .account import *
from .data import *
from .forms import ReportForm
from .models import Preference, Report, User
from .models import APIdentity, Preference, Report, User
def render_user_not_found(request):
def render_user_not_found(request, user_name=""):
sec_msg = _("😖哎呀这位用户好像还没有加入本站快去联邦宇宙呼唤TA来注册吧")
msg = _("未找到用户")
msg = _("未找到用户") + user_name
return render(
request,
"common/error.html",
@ -42,6 +44,37 @@ def render_user_blocked(request):
)
def query_identity(request, handle):
try:
i = APIdentity.get_by_handler(handle)
return redirect(i.url)
except APIdentity.DoesNotExist:
if len(handle.split("@")) == 3:
Takahe.fetch_remote_identity(handle)
return render(
request, "users/fetch_identity_pending.html", {"handle": handle}
)
else:
return render_user_not_found(request, handle)
def fetch_refresh(request):
handle = request.GET.get("handle", "")
try:
i = APIdentity.get_by_handler(handle)
return HTTPResponseHXRedirect(i.url)
except:
retry = int(request.GET.get("retry", 0)) + 1
if retry > 10:
return render(request, "users/fetch_identity_failed.html")
else:
return render(
request,
"users/fetch_identity_refresh.html",
{"handle": handle, "retry": retry, "delay": retry * 2},
)
@login_required
def follow(request, user_name):
if request.method != "POST":