Outgoing mentions mostly work (exc. cc followers)

This commit is contained in:
Andrew Godwin 2022-11-21 21:18:13 -07:00
parent a80e0f117a
commit 53d9452917
9 changed files with 171 additions and 64 deletions

View File

@ -37,7 +37,7 @@ the less sure I am about it.
- [x] Undo follows - [x] Undo follows
- [x] Receive and accept follows - [x] Receive and accept follows
- [x] Receive follow undos - [x] Receive follow undos
- [ ] Do outgoing mentions properly - [x] Do outgoing mentions properly
- [x] Home timeline (posts and boosts from follows) - [x] Home timeline (posts and boosts from follows)
- [x] Notifications page (followed, boosted, liked) - [x] Notifications page (followed, boosted, liked)
- [x] Local timeline - [x] Local timeline

View File

@ -4,12 +4,13 @@ from typing import Dict, Optional
import httpx import httpx
import urlman import urlman
from django.db import models, transaction from django.db import models, transaction
from django.template.defaultfilters import linebreaks_filter
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from activities.models.fan_out import FanOut from activities.models.fan_out import FanOut
from activities.models.timeline_event import TimelineEvent from activities.models.timeline_event import TimelineEvent
from core.html import sanitize_post from core.html import sanitize_post, strip_html
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
from stator.models import State, StateField, StateGraph, StatorModel from stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import Follow from users.models.follow import Follow
@ -134,7 +135,6 @@ class Post(StatorModel):
class urls(urlman.Urls): class urls(urlman.Urls):
view = "{self.author.urls.view}posts/{self.id}/" view = "{self.author.urls.view}posts/{self.id}/"
view_nice = "{self.author.urls.view_nice}posts/{self.id}/"
object_uri = "{self.author.actor_uri}posts/{self.id}/" object_uri = "{self.author.actor_uri}posts/{self.id}/"
action_like = "{view}like/" action_like = "{view}like/"
action_unlike = "{view}unlike/" action_unlike = "{view}unlike/"
@ -153,42 +153,58 @@ class Post(StatorModel):
def get_absolute_url(self): def get_absolute_url(self):
return self.urls.view return self.urls.view
def absolute_object_uri(self):
"""
Returns an object URI that is always absolute, for sending out to
other servers.
"""
if self.local:
return self.author.absolute_profile_uri() + f"posts/{self.id}/"
else:
return self.object_uri
### Content cleanup and extraction ### ### Content cleanup and extraction ###
mention_regex = re.compile( mention_regex = re.compile(
r"([^\w\d\-_])(@[\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)" r"([^\w\d\-_])@([\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)"
) )
def linkify_mentions(self, content): def linkify_mentions(self, content, local=False):
""" """
Links mentions _in the context of the post_ - meaning that if there's Links mentions _in the context of the post_ - as in, using the mentions
a short @andrew mention, it will look at the mentions link to resolve property as the only source (as we might be doing this without other
it rather than presuming it's local. DB access allowed)
""" """
possible_matches = {}
for mention in self.mentions.all():
if local:
url = str(mention.urls.view)
else:
url = mention.absolute_profile_uri()
possible_matches[mention.username] = url
possible_matches[f"{mention.username}@{mention.domain_id}"] = url
def replacer(match): def replacer(match):
precursor = match.group(1) precursor = match.group(1)
handle = match.group(2) handle = match.group(2)
# If the handle has no domain, try to match it with a mention if handle in possible_matches:
if "@" not in handle.lstrip("@"): return f'{precursor}<a href="{possible_matches[handle]}">@{handle}</a>'
username = handle.lstrip("@")
identity = self.mentions.filter(username=username).first()
if identity:
url = identity.urls.view
else:
url = f"/@{username}/"
else:
url = f"/{handle}/"
# If we have a URL, link to it, otherwise don't link
if url:
return f'{precursor}<a href="{url}">{handle}</a>'
else: else:
return match.group() return match.group()
return mark_safe(self.mention_regex.sub(replacer, content)) return mark_safe(self.mention_regex.sub(replacer, content))
@property def safe_content_local(self):
def safe_content(self): """
Returns the content formatted for local display
"""
return self.linkify_mentions(sanitize_post(self.content), local=True)
def safe_content_remote(self):
"""
Returns the content formatted for remote consumption
"""
return self.linkify_mentions(sanitize_post(self.content)) return self.linkify_mentions(sanitize_post(self.content))
### Async helpers ### ### Async helpers ###
@ -197,8 +213,10 @@ class Post(StatorModel):
""" """
Returns a version of the object with all relations pre-loaded Returns a version of the object with all relations pre-loaded
""" """
return await Post.objects.select_related("author", "author__domain").aget( return (
pk=self.pk await Post.objects.select_related("author", "author__domain")
.prefetch_related("mentions", "mentions__domain")
.aget(pk=self.pk)
) )
### Local creation ### ### Local creation ###
@ -212,6 +230,25 @@ class Post(StatorModel):
visibility: int = Visibilities.public, visibility: int = Visibilities.public,
) -> "Post": ) -> "Post":
with transaction.atomic(): with transaction.atomic():
# Find mentions in this post
mention_hits = cls.mention_regex.findall(content)
mentions = set()
for precursor, handle in mention_hits:
if "@" in handle:
username, domain = handle.split("@", 1)
else:
username = handle
domain = author.domain_id
identity = Identity.by_username_and_domain(
username=username,
domain=domain,
fetch=True,
)
if identity is not None:
mentions.add(identity)
# Strip all HTML and apply linebreaks filter
content = linebreaks_filter(strip_html(content))
# Make the Post object
post = cls.objects.create( post = cls.objects.create(
author=author, author=author,
content=content, content=content,
@ -221,7 +258,8 @@ class Post(StatorModel):
visibility=visibility, visibility=visibility,
) )
post.object_uri = post.urls.object_uri post.object_uri = post.urls.object_uri
post.url = post.urls.view_nice post.url = post.absolute_object_uri()
post.mentions.set(mentions)
post.save() post.save()
return post return post
@ -232,28 +270,48 @@ class Post(StatorModel):
Returns the AP JSON for this object Returns the AP JSON for this object
""" """
value = { value = {
"to": "as:Public",
"cc": [],
"type": "Note", "type": "Note",
"id": self.object_uri, "id": self.object_uri,
"published": format_ld_date(self.published), "published": format_ld_date(self.published),
"attributedTo": self.author.actor_uri, "attributedTo": self.author.actor_uri,
"content": self.safe_content, "content": self.safe_content_remote(),
"to": "as:Public",
"as:sensitive": self.sensitive, "as:sensitive": self.sensitive,
"url": str(self.urls.view_nice if self.local else self.url), "url": self.absolute_object_uri(),
"tag": [],
} }
if self.summary: if self.summary:
value["summary"] = self.summary value["summary"] = self.summary
# Mentions
for mention in self.mentions.all():
value["tag"].append(
{
"href": mention.actor_uri,
"name": "@" + mention.handle,
"type": "Mention",
}
)
value["cc"].append(mention.actor_uri)
# Remove tag and cc if they're empty
if not value["cc"]:
del value["cc"]
if not value["tag"]:
del value["tag"]
return value return value
def to_create_ap(self): def to_create_ap(self):
""" """
Returns the AP JSON to create this object Returns the AP JSON to create this object
""" """
object = self.to_ap()
return { return {
"to": object["to"],
"cc": object.get("cc", []),
"type": "Create", "type": "Create",
"id": self.object_uri + "#create", "id": self.object_uri + "#create",
"actor": self.author.actor_uri, "actor": self.author.actor_uri,
"object": self.to_ap(), "object": object,
} }
### ActivityPub (inbound) ### ### ActivityPub (inbound) ###

View File

@ -1,6 +1,5 @@
from django import forms from django import forms
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.defaultfilters import linebreaks_filter
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView, View from django.views.generic import FormView, TemplateView, View
@ -158,7 +157,7 @@ class Compose(FormView):
def form_valid(self, form): def form_valid(self, form):
Post.create_local( Post.create_local(
author=self.request.identity, author=self.request.identity,
content=linebreaks_filter(form.cleaned_data["text"]), content=form.cleaned_data["text"],
summary=form.cleaned_data.get("content_warning"), summary=form.cleaned_data.get("content_warning"),
visibility=form.cleaned_data["visibility"], visibility=form.cleaned_data["visibility"],
) )

View File

@ -30,3 +30,11 @@ def sanitize_post(post_html: str) -> str:
strip=True, strip=True,
) )
return mark_safe(cleaner.clean(post_html)) return mark_safe(cleaner.clean(post_html))
def strip_html(post_html: str) -> str:
"""
Strips all tags from the text, then linkifies it.
"""
cleaner = bleach.Cleaner(tags=[], strip=True, filters=[LinkifyFilter])
return mark_safe(cleaner.clean(post_html))

View File

@ -36,7 +36,7 @@
{% endif %} {% endif %}
<div class="content {% if post.summary %}hidden{% endif %}"> <div class="content {% if post.summary %}hidden{% endif %}">
{{ post.safe_content }} {{ post.safe_content_local }}
</div> </div>
{% if post.attachments.exists %} {% if post.attachments.exists %}

View File

@ -32,32 +32,72 @@ def test_fetch_post(httpx_mock: HTTPXMock):
@pytest.mark.django_db @pytest.mark.django_db
def test_linkify_mentions(identity, remote_identity): def test_linkify_mentions_remote(identity, remote_identity):
""" """
Tests that we can linkify post mentions properly Tests that we can linkify post mentions properly for remote use
""" """
# Test a short username without a mention (presumed local) # Test a short username (remote)
post = Post.objects.create(
content="<p>Hello @test</p>",
author=identity,
local=True,
)
assert post.safe_content == '<p>Hello <a href="/@test/">@test</a></p>'
# Test a full username
post = Post.objects.create(
content="<p>@test@example.com, welcome!</p>",
author=identity,
local=True,
)
assert (
post.safe_content
== '<p><a href="/@test@example.com/">@test@example.com</a>, welcome!</p>'
)
# Test a short username with a mention resolving to remote
post = Post.objects.create( post = Post.objects.create(
content="<p>Hello @test</p>", content="<p>Hello @test</p>",
author=identity, author=identity,
local=True, local=True,
) )
post.mentions.add(remote_identity) post.mentions.add(remote_identity)
assert post.safe_content == '<p>Hello <a href="/@test@remote.test/">@test</a></p>' assert (
post.safe_content_remote()
== '<p>Hello <a href="https://remote.test/@test/">@test</a></p>'
)
# Test a full username (local)
post = Post.objects.create(
content="<p>@test@example.com, welcome!</p>",
author=identity,
local=True,
)
post.mentions.add(identity)
assert (
post.safe_content_remote()
== '<p><a href="https://example.com/@test/">@test@example.com</a>, welcome!</p>'
)
# Test that they don't get touched without a mention
post = Post.objects.create(
content="<p>@test@example.com, welcome!</p>",
author=identity,
local=True,
)
assert post.safe_content_remote() == "<p>@test@example.com, welcome!</p>"
@pytest.mark.django_db
def test_linkify_mentions_local(identity, remote_identity):
"""
Tests that we can linkify post mentions properly for local use
"""
# Test a short username (remote)
post = Post.objects.create(
content="<p>Hello @test</p>",
author=identity,
local=True,
)
post.mentions.add(remote_identity)
assert (
post.safe_content_local()
== '<p>Hello <a href="/@test@remote.test/">@test</a></p>'
)
# Test a full username (local)
post = Post.objects.create(
content="<p>@test@example.com, welcome!</p>",
author=identity,
local=True,
)
post.mentions.add(identity)
assert (
post.safe_content_local()
== '<p><a href="/@test@example.com/">@test@example.com</a>, welcome!</p>'
)
# Test that they don't get touched without a mention
post = Post.objects.create(
content="<p>@test@example.com, welcome!</p>",
author=identity,
local=True,
)
assert post.safe_content_local() == "<p>@test@example.com, welcome!</p>"

View File

@ -68,14 +68,15 @@ def identity():
""" """
user = User.objects.create(email="test@example.com") user = User.objects.create(email="test@example.com")
domain = Domain.objects.create(domain="example.com", local=True, public=True) domain = Domain.objects.create(domain="example.com", local=True, public=True)
return Identity.objects.create( identity = Identity.objects.create(
actor_uri="https://example.com/test-actor/", actor_uri="https://example.com/test-actor/",
username="test", username="test",
domain=domain, domain=domain,
user=user,
name="Test User", name="Test User",
local=True, local=True,
) )
identity.users.set([user])
return identity
@pytest.fixture @pytest.fixture
@ -87,6 +88,7 @@ def remote_identity():
domain = Domain.objects.create(domain="remote.test", local=False) domain = Domain.objects.create(domain="remote.test", local=False)
return Identity.objects.create( return Identity.objects.create(
actor_uri="https://remote.test/test-actor/", actor_uri="https://remote.test/test-actor/",
profile_uri="https://remote.test/@test/",
username="test", username="test",
domain=domain, domain=domain,
name="Test Remote User", name="Test Remote User",

View File

@ -97,7 +97,6 @@ class Identity(StatorModel):
unique_together = [("username", "domain")] unique_together = [("username", "domain")]
class urls(urlman.Urls): class urls(urlman.Urls):
view_nice = "{self._nice_view_url}"
view = "/@{self.username}@{self.domain_id}/" view = "/@{self.username}@{self.domain_id}/"
action = "{view}action/" action = "{view}action/"
activate = "{view}activate/" activate = "{view}activate/"
@ -113,14 +112,15 @@ class Identity(StatorModel):
return self.handle return self.handle
return self.actor_uri return self.actor_uri
def _nice_view_url(self): def absolute_profile_uri(self):
""" """
Returns the "nice" user URL if they're local, otherwise our general one Returns a profile URI that is always absolute, for sending out to
other servers.
""" """
if self.local: if self.local:
return f"https://{self.domain.uri_domain}/@{self.username}/" return f"https://{self.domain.uri_domain}/@{self.username}/"
else: else:
return f"/@{self.username}@{self.domain_id}/" return self.profile_uri
def local_icon_url(self): def local_icon_url(self):
""" """
@ -206,7 +206,7 @@ class Identity(StatorModel):
def handle(self): def handle(self):
if self.domain_id: if self.domain_id:
return f"{self.username}@{self.domain_id}" return f"{self.username}@{self.domain_id}"
return f"{self.username}@UNKNOWN-DOMAIN" return f"{self.username}@unknown.invalid"
@property @property
def data_age(self) -> float: def data_age(self) -> float:
@ -238,7 +238,7 @@ class Identity(StatorModel):
"publicKeyPem": self.public_key, "publicKeyPem": self.public_key,
}, },
"published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"), "published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
"url": str(self.urls.view_nice), "url": self.absolute_profile_uri(),
} }
if self.name: if self.name:
response["name"] = self.name response["name"] = self.name

View File

@ -128,13 +128,13 @@ class Webfinger(View):
{ {
"subject": f"acct:{identity.handle}", "subject": f"acct:{identity.handle}",
"aliases": [ "aliases": [
str(identity.urls.view_nice), identity.absolute_profile_uri(),
], ],
"links": [ "links": [
{ {
"rel": "http://webfinger.net/rel/profile-page", "rel": "http://webfinger.net/rel/profile-page",
"type": "text/html", "type": "text/html",
"href": str(identity.urls.view_nice), "href": identity.absolute_profile_uri(),
}, },
{ {
"rel": "self", "rel": "self",