Merge branch 'main' into report-actions
This commit is contained in:
commit
0818d5aabb
39 changed files with 524 additions and 101 deletions
|
@ -529,7 +529,7 @@ async def async_broadcast(recipients: List[str], sender, data: str):
|
|||
|
||||
|
||||
async def sign_and_send(
|
||||
session: aiohttp.ClientSession, sender, data: str, destination: str
|
||||
session: aiohttp.ClientSession, sender, data: str, destination: str, **kwargs
|
||||
):
|
||||
"""Sign the messages and send them in an asynchronous bundle"""
|
||||
now = http_date()
|
||||
|
@ -539,11 +539,19 @@ async def sign_and_send(
|
|||
raise ValueError("No private key found for sender")
|
||||
|
||||
digest = make_digest(data)
|
||||
signature = make_signature(
|
||||
"post",
|
||||
sender,
|
||||
destination,
|
||||
now,
|
||||
digest=digest,
|
||||
use_legacy_key=kwargs.get("use_legacy_key"),
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Date": now,
|
||||
"Digest": digest,
|
||||
"Signature": make_signature("post", sender, destination, now, digest),
|
||||
"Signature": signature,
|
||||
"Content-Type": "application/activity+json; charset=utf-8",
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
|
@ -554,6 +562,14 @@ async def sign_and_send(
|
|||
logger.exception(
|
||||
"Failed to send broadcast to %s: %s", destination, response.reason
|
||||
)
|
||||
if kwargs.get("use_legacy_key") is not True:
|
||||
logger.info("Trying again with legacy keyId header value")
|
||||
asyncio.ensure_future(
|
||||
sign_and_send(
|
||||
session, sender, data, destination, use_legacy_key=True
|
||||
)
|
||||
)
|
||||
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Connection timed out for url: %s", destination)
|
||||
|
|
|
@ -371,7 +371,7 @@ class TagField(ManyToManyField):
|
|||
tags.append(
|
||||
activitypub.Link(
|
||||
href=item.remote_id,
|
||||
name=getattr(item, item.name_field),
|
||||
name=f"@{getattr(item, item.name_field)}",
|
||||
type=activity_type,
|
||||
)
|
||||
)
|
||||
|
@ -379,7 +379,12 @@ class TagField(ManyToManyField):
|
|||
|
||||
def field_from_activity(self, value, allow_external_connections=True):
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
# GoToSocial DMs and single-user mentions are
|
||||
# sent as objects, not as an array of objects
|
||||
if isinstance(value, dict):
|
||||
value = [value]
|
||||
else:
|
||||
return None
|
||||
items = []
|
||||
for link_json in value:
|
||||
link = activitypub.Link(**link_json)
|
||||
|
|
|
@ -142,10 +142,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
# keep notes if they mention local users
|
||||
if activity.tag == MISSING or activity.tag is None:
|
||||
return True
|
||||
tags = [l["href"] for l in activity.tag if l["type"] == "Mention"]
|
||||
# GoToSocial sends single tags as objects
|
||||
# not wrapped in a list
|
||||
tags = activity.tag if isinstance(activity.tag, list) else [activity.tag]
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
for tag in tags:
|
||||
if user_model.objects.filter(remote_id=tag, local=True).exists():
|
||||
if (
|
||||
tag["type"] == "Mention"
|
||||
and user_model.objects.filter(
|
||||
remote_id=tag["href"], local=True
|
||||
).exists()
|
||||
):
|
||||
# we found a mention of a known use boost
|
||||
return False
|
||||
return True
|
||||
|
|
|
@ -339,7 +339,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
# this is a new remote user, we need to set their remote server field
|
||||
if not self.local:
|
||||
super().save(*args, **kwargs)
|
||||
transaction.on_commit(lambda: set_remote_server.delay(self.id))
|
||||
transaction.on_commit(lambda: set_remote_server(self.id))
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
|
@ -470,17 +470,29 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
|
||||
|
||||
@app.task(queue=LOW)
|
||||
def set_remote_server(user_id):
|
||||
def set_remote_server(user_id, allow_external_connections=False):
|
||||
"""figure out the user's remote server in the background"""
|
||||
user = User.objects.get(id=user_id)
|
||||
actor_parts = urlparse(user.remote_id)
|
||||
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
|
||||
federated_server = get_or_create_remote_server(
|
||||
actor_parts.netloc, allow_external_connections=allow_external_connections
|
||||
)
|
||||
# if we were unable to find the server, we need to create a new entry for it
|
||||
if not federated_server:
|
||||
# and to do that, we will call this function asynchronously.
|
||||
if not allow_external_connections:
|
||||
set_remote_server.delay(user_id, allow_external_connections=True)
|
||||
return
|
||||
|
||||
user.federated_server = federated_server
|
||||
user.save(broadcast=False, update_fields=["federated_server"])
|
||||
if user.bookwyrm_user and user.outbox:
|
||||
get_remote_reviews.delay(user.outbox)
|
||||
|
||||
|
||||
def get_or_create_remote_server(domain, refresh=False):
|
||||
def get_or_create_remote_server(
|
||||
domain, allow_external_connections=False, refresh=False
|
||||
):
|
||||
"""get info on a remote server"""
|
||||
server = FederatedServer()
|
||||
try:
|
||||
|
@ -490,6 +502,9 @@ def get_or_create_remote_server(domain, refresh=False):
|
|||
except FederatedServer.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not allow_external_connections:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = get_data(f"https://{domain}/.well-known/nodeinfo")
|
||||
try:
|
||||
|
|
|
@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
|||
env = Env()
|
||||
env.read_env()
|
||||
DOMAIN = env("DOMAIN")
|
||||
VERSION = "0.6.2"
|
||||
VERSION = "0.6.3"
|
||||
|
||||
RELEASE_API = env(
|
||||
"RELEASE_API",
|
||||
|
@ -22,7 +22,7 @@ RELEASE_API = env(
|
|||
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
|
||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||
|
||||
JS_CACHE = "ea91d7df"
|
||||
JS_CACHE = "d993847c"
|
||||
|
||||
# email
|
||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
|
|
@ -22,7 +22,7 @@ def create_key_pair():
|
|||
return private_key, public_key
|
||||
|
||||
|
||||
def make_signature(method, sender, destination, date, digest=None):
|
||||
def make_signature(method, sender, destination, date, **kwargs):
|
||||
"""uses a private key to sign an outgoing message"""
|
||||
inbox_parts = urlparse(destination)
|
||||
signature_headers = [
|
||||
|
@ -31,6 +31,7 @@ def make_signature(method, sender, destination, date, digest=None):
|
|||
f"date: {date}",
|
||||
]
|
||||
headers = "(request-target) host date"
|
||||
digest = kwargs.get("digest")
|
||||
if digest is not None:
|
||||
signature_headers.append(f"digest: {digest}")
|
||||
headers = "(request-target) host date digest"
|
||||
|
@ -38,8 +39,14 @@ def make_signature(method, sender, destination, date, digest=None):
|
|||
message_to_sign = "\n".join(signature_headers)
|
||||
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
|
||||
signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
|
||||
# For legacy reasons we need to use an incorrect keyId for older Bookwyrm versions
|
||||
key_id = (
|
||||
f"{sender.remote_id}#main-key"
|
||||
if kwargs.get("use_legacy_key")
|
||||
else f"{sender.remote_id}/#main-key"
|
||||
)
|
||||
signature = {
|
||||
"keyId": f"{sender.remote_id}#main-key",
|
||||
"keyId": key_id,
|
||||
"algorithm": "rsa-sha256",
|
||||
"headers": headers,
|
||||
"signature": b64encode(signed_message).decode("utf8"),
|
||||
|
|
|
@ -5,6 +5,10 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stars .no-rating {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/** Stars in a review form
|
||||
*
|
||||
* Specificity makes hovering taking over checked inputs.
|
||||
|
|
|
@ -40,9 +40,6 @@ let BookWyrm = new (class {
|
|||
|
||||
document.querySelectorAll("details.dropdown").forEach((node) => {
|
||||
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this));
|
||||
node.querySelectorAll("[data-modal-open]").forEach((modal_node) =>
|
||||
modal_node.addEventListener("click", () => (node.open = false))
|
||||
);
|
||||
});
|
||||
|
||||
document
|
||||
|
|
|
@ -190,13 +190,15 @@
|
|||
<meta itemprop="bestRating" content="5">
|
||||
<meta itemprop="reviewCount" content="{{ review_count }}">
|
||||
|
||||
{% include 'snippets/stars.html' with rating=rating %}
|
||||
<span>
|
||||
{% include 'snippets/stars.html' with rating=rating %}
|
||||
|
||||
{% blocktrans count counter=review_count trimmed %}
|
||||
({{ review_count }} review)
|
||||
{% plural %}
|
||||
({{ review_count }} reviews)
|
||||
{% endblocktrans %}
|
||||
{% blocktrans count counter=review_count trimmed %}
|
||||
({{ review_count }} review)
|
||||
{% plural %}
|
||||
({{ review_count }} reviews)
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% with full=book|book_description itemprop='abstract' %}
|
||||
|
|
|
@ -2,26 +2,25 @@
|
|||
{% load i18n %}
|
||||
|
||||
<span class="stars">
|
||||
<span class="is-sr-only">
|
||||
{% if rating %}
|
||||
{% if rating %}
|
||||
<span class="is-sr-only">
|
||||
{% blocktranslate trimmed with rating=rating|floatformat:0 count counter=rating|floatformat:0|add:0 %}
|
||||
{{ rating }} star
|
||||
{% plural %}
|
||||
{{ rating }} stars
|
||||
{% endblocktranslate %}
|
||||
{% else %}
|
||||
{% trans "No rating" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% for i in '12345'|make_list %}
|
||||
<span
|
||||
class="
|
||||
icon is-small mr-1
|
||||
icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}
|
||||
"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% for i in '12345'|make_list %}
|
||||
<span
|
||||
class="
|
||||
icon is-small mr-1
|
||||
icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}
|
||||
"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="no-rating">{% trans "No rating" %}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -404,7 +404,7 @@ class ModelFields(TestCase):
|
|||
self.assertIsInstance(result, list)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].href, "https://e.b/c")
|
||||
self.assertEqual(result[0].name, "Name")
|
||||
self.assertEqual(result[0].name, "@Name")
|
||||
self.assertEqual(result[0].type, "Serializable")
|
||||
|
||||
def test_tag_field_from_activity(self, *_):
|
||||
|
|
|
@ -162,7 +162,9 @@ class User(TestCase):
|
|||
json={"software": {"name": "hi", "version": "2"}},
|
||||
)
|
||||
|
||||
server = models.user.get_or_create_remote_server(DOMAIN)
|
||||
server = models.user.get_or_create_remote_server(
|
||||
DOMAIN, allow_external_connections=True
|
||||
)
|
||||
self.assertEqual(server.server_name, DOMAIN)
|
||||
self.assertEqual(server.application_type, "hi")
|
||||
self.assertEqual(server.application_version, "2")
|
||||
|
@ -173,7 +175,9 @@ class User(TestCase):
|
|||
responses.GET, f"https://{DOMAIN}/.well-known/nodeinfo", status=404
|
||||
)
|
||||
|
||||
server = models.user.get_or_create_remote_server(DOMAIN)
|
||||
server = models.user.get_or_create_remote_server(
|
||||
DOMAIN, allow_external_connections=True
|
||||
)
|
||||
self.assertEqual(server.server_name, DOMAIN)
|
||||
self.assertIsNone(server.application_type)
|
||||
self.assertIsNone(server.application_version)
|
||||
|
@ -187,7 +191,9 @@ class User(TestCase):
|
|||
)
|
||||
responses.add(responses.GET, "http://www.example.com", status=404)
|
||||
|
||||
server = models.user.get_or_create_remote_server(DOMAIN)
|
||||
server = models.user.get_or_create_remote_server(
|
||||
DOMAIN, allow_external_connections=True
|
||||
)
|
||||
self.assertEqual(server.server_name, DOMAIN)
|
||||
self.assertIsNone(server.application_type)
|
||||
self.assertIsNone(server.application_version)
|
||||
|
@ -201,7 +207,9 @@ class User(TestCase):
|
|||
)
|
||||
responses.add(responses.GET, "http://www.example.com", json={"fish": "salmon"})
|
||||
|
||||
server = models.user.get_or_create_remote_server(DOMAIN)
|
||||
server = models.user.get_or_create_remote_server(
|
||||
DOMAIN, allow_external_connections=True
|
||||
)
|
||||
self.assertEqual(server.server_name, DOMAIN)
|
||||
self.assertIsNone(server.application_type)
|
||||
self.assertIsNone(server.application_version)
|
||||
|
|
|
@ -87,7 +87,7 @@ class Signature(TestCase):
|
|||
data = json.dumps(get_follow_activity(sender, self.rat))
|
||||
digest = digest or make_digest(data)
|
||||
signature = make_signature(
|
||||
"post", signer or sender, self.rat.inbox, now, digest
|
||||
"post", signer or sender, self.rat.inbox, now, digest=digest
|
||||
)
|
||||
with patch("bookwyrm.views.inbox.activity_task.apply_async"):
|
||||
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
||||
|
@ -111,6 +111,7 @@ class Signature(TestCase):
|
|||
datafile = pathlib.Path(__file__).parent.joinpath("data/ap_user.json")
|
||||
data = json.loads(datafile.read_bytes())
|
||||
data["id"] = self.fake_remote.remote_id
|
||||
data["publicKey"]["id"] = f"{self.fake_remote.remote_id}/#main-key"
|
||||
data["publicKey"]["publicKeyPem"] = self.fake_remote.key_pair.public_key
|
||||
del data["icon"] # Avoid having to return an avatar.
|
||||
responses.add(responses.GET, self.fake_remote.remote_id, json=data, status=200)
|
||||
|
@ -138,6 +139,7 @@ class Signature(TestCase):
|
|||
datafile = pathlib.Path(__file__).parent.joinpath("data/ap_user.json")
|
||||
data = json.loads(datafile.read_bytes())
|
||||
data["id"] = self.fake_remote.remote_id
|
||||
data["publicKey"]["id"] = f"{self.fake_remote.remote_id}/#main-key"
|
||||
data["publicKey"]["publicKeyPem"] = self.fake_remote.key_pair.public_key
|
||||
del data["icon"] # Avoid having to return an avatar.
|
||||
responses.add(responses.GET, self.fake_remote.remote_id, json=data, status=200)
|
||||
|
@ -157,7 +159,7 @@ class Signature(TestCase):
|
|||
"bookwyrm.models.relationship.UserFollowRequest.accept"
|
||||
) as accept_mock:
|
||||
response = self.send_test_request(sender=self.fake_remote)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 200) # BUG this is 401
|
||||
self.assertTrue(accept_mock.called)
|
||||
|
||||
# Old key is cached, so still works:
|
||||
|
|
|
@ -45,6 +45,7 @@ class EditBook(View):
|
|||
data = {"book": book, "form": form}
|
||||
ensure_transient_values_persist(request, data)
|
||||
if not form.is_valid():
|
||||
ensure_transient_values_persist(request, data, add_author=True)
|
||||
return TemplateResponse(request, "book/edit/edit_book.html", data)
|
||||
|
||||
data = add_authors(request, data)
|
||||
|
@ -102,11 +103,13 @@ class CreateBook(View):
|
|||
"authors": authors,
|
||||
}
|
||||
|
||||
ensure_transient_values_persist(request, data)
|
||||
|
||||
if not form.is_valid():
|
||||
ensure_transient_values_persist(request, data, form=form)
|
||||
return TemplateResponse(request, "book/edit/edit_book.html", data)
|
||||
|
||||
# we have to call this twice because it requires form.cleaned_data
|
||||
# which only exists after we validate the form
|
||||
ensure_transient_values_persist(request, data, form=form)
|
||||
data = add_authors(request, data)
|
||||
|
||||
# check if this is an edition of an existing work
|
||||
|
@ -139,9 +142,15 @@ class CreateBook(View):
|
|||
return redirect(f"/book/{book.id}")
|
||||
|
||||
|
||||
def ensure_transient_values_persist(request, data):
|
||||
def ensure_transient_values_persist(request, data, **kwargs):
|
||||
"""ensure that values of transient form fields persist when re-rendering the form"""
|
||||
data["cover_url"] = request.POST.get("cover-url")
|
||||
if kwargs and kwargs.get("form"):
|
||||
data["book"] = data.get("book") or {}
|
||||
data["book"]["subjects"] = kwargs["form"].cleaned_data["subjects"]
|
||||
data["add_author"] = request.POST.getlist("add_author")
|
||||
elif kwargs and kwargs.get("add_author") is True:
|
||||
data["add_author"] = request.POST.getlist("add_author")
|
||||
|
||||
|
||||
def add_authors(request, data):
|
||||
|
|
|
@ -3,7 +3,6 @@ import json
|
|||
import re
|
||||
import logging
|
||||
|
||||
from urllib.parse import urldefrag
|
||||
import requests
|
||||
|
||||
from django.http import HttpResponse, Http404
|
||||
|
@ -130,15 +129,18 @@ def has_valid_signature(request, activity):
|
|||
"""verify incoming signature"""
|
||||
try:
|
||||
signature = Signature.parse(request)
|
||||
|
||||
key_actor = urldefrag(signature.key_id).url
|
||||
if key_actor != activity.get("actor"):
|
||||
raise ValueError("Wrong actor created signature.")
|
||||
|
||||
remote_user = activitypub.resolve_remote_id(key_actor, model=models.User)
|
||||
remote_user = activitypub.resolve_remote_id(
|
||||
activity.get("actor"), model=models.User
|
||||
)
|
||||
if not remote_user:
|
||||
return False
|
||||
|
||||
if signature.key_id != remote_user.key_pair.remote_id:
|
||||
if (
|
||||
signature.key_id != f"{remote_user.remote_id}#main-key"
|
||||
): # legacy Bookwyrm
|
||||
raise ValueError("Wrong actor created signature.")
|
||||
|
||||
try:
|
||||
signature.verify(remote_user.key_pair.public_key, request)
|
||||
except ValueError:
|
||||
|
|
|
@ -36,14 +36,22 @@ class RssFeed(Feed):
|
|||
|
||||
def items(self, obj):
|
||||
"""the user's activity feed"""
|
||||
return obj.status_set.select_subclasses().filter(
|
||||
privacy__in=["public", "unlisted"],
|
||||
)[:10]
|
||||
return (
|
||||
obj.status_set.select_subclasses()
|
||||
.filter(
|
||||
privacy__in=["public", "unlisted"],
|
||||
)
|
||||
.order_by("-published_date")[:10]
|
||||
)
|
||||
|
||||
def item_link(self, item):
|
||||
"""link to the status"""
|
||||
return item.local_path
|
||||
|
||||
def item_pubdate(self, item):
|
||||
"""publication date of the item"""
|
||||
return item.published_date
|
||||
|
||||
|
||||
class RssReviewsOnlyFeed(Feed):
|
||||
"""serialize user's reviews in rss feed"""
|
||||
|
@ -76,12 +84,16 @@ class RssReviewsOnlyFeed(Feed):
|
|||
return Review.objects.filter(
|
||||
user=obj,
|
||||
privacy__in=["public", "unlisted"],
|
||||
)[:10]
|
||||
).order_by("-published_date")[:10]
|
||||
|
||||
def item_link(self, item):
|
||||
"""link to the status"""
|
||||
return item.local_path
|
||||
|
||||
def item_pubdate(self, item):
|
||||
"""publication date of the item"""
|
||||
return item.published_date
|
||||
|
||||
|
||||
class RssQuotesOnlyFeed(Feed):
|
||||
"""serialize user's quotes in rss feed"""
|
||||
|
@ -114,12 +126,16 @@ class RssQuotesOnlyFeed(Feed):
|
|||
return Quotation.objects.filter(
|
||||
user=obj,
|
||||
privacy__in=["public", "unlisted"],
|
||||
)[:10]
|
||||
).order_by("-published_date")[:10]
|
||||
|
||||
def item_link(self, item):
|
||||
"""link to the status"""
|
||||
return item.local_path
|
||||
|
||||
def item_pubdate(self, item):
|
||||
"""publication date of the item"""
|
||||
return item.published_date
|
||||
|
||||
|
||||
class RssCommentsOnlyFeed(Feed):
|
||||
"""serialize user's quotes in rss feed"""
|
||||
|
@ -152,8 +168,12 @@ class RssCommentsOnlyFeed(Feed):
|
|||
return Comment.objects.filter(
|
||||
user=obj,
|
||||
privacy__in=["public", "unlisted"],
|
||||
)[:10]
|
||||
).order_by("-published_date")[:10]
|
||||
|
||||
def item_link(self, item):
|
||||
"""link to the status"""
|
||||
return item.local_path
|
||||
|
||||
def item_pubdate(self, item):
|
||||
"""publication date of the item"""
|
||||
return item.published_date
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue