diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 95a0ebfb0..4e7be4af3 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -13,3 +13,5 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 - uses: psf/black@22.12.0 + with: + version: 22.12.0 diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 095ec0227..02e5395fc 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -2,11 +2,16 @@ from dataclasses import dataclass, fields, MISSING from json import JSONEncoder import logging +import requests from django.apps import apps from django.db import IntegrityError, transaction +from django.utils.http import http_date +from bookwyrm import models from bookwyrm.connectors import ConnectorException, get_data +from bookwyrm.signatures import make_signature +from bookwyrm.settings import DOMAIN, INSTANCE_ACTOR_USERNAME from bookwyrm.tasks import app, MEDIUM logger = logging.getLogger(__name__) @@ -246,10 +251,10 @@ def set_related_field( def get_model_from_type(activity_type): """given the activity, what type of model""" - models = apps.get_models() + activity_models = apps.get_models() model = [ m - for m in models + for m in activity_models if hasattr(m, "activity_serializer") and hasattr(m.activity_serializer, "type") and m.activity_serializer.type == activity_type @@ -275,10 +280,16 @@ def resolve_remote_id( # load the data and create the object try: data = get_data(remote_id) - except ConnectorException: + except ConnectionError: logger.info("Could not connect to host for remote_id: %s", remote_id) return None - + except requests.HTTPError as e: + if (e.response is not None) and e.response.status_code == 401: + # This most likely means it's a mastodon with secure fetch enabled. + data = get_activitypub_data(remote_id) + else: + logger.info("Could not connect to host for remote_id: %s", remote_id) + return None # determine the model implicitly, if not provided # or if it's a model with subclasses like Status, check again if not model or hasattr(model.objects, "select_subclasses"): @@ -297,6 +308,51 @@ def resolve_remote_id( return item.to_model(model=model, instance=result, save=save) +def get_representative(): + """Get or create an actor representing the instance + to sign requests to 'secure mastodon' servers""" + username = f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}" + email = "bookwyrm@localhost" + try: + user = models.User.objects.get(username=username) + except models.User.DoesNotExist: + user = models.User.objects.create_user( + username=username, + email=email, + local=True, + localname=INSTANCE_ACTOR_USERNAME, + ) + return user + + +def get_activitypub_data(url): + """wrapper for request.get""" + now = http_date() + sender = get_representative() + if not sender.key_pair.private_key: + # this shouldn't happen. it would be bad if it happened. + raise ValueError("No private key found for sender") + try: + resp = requests.get( + url, + headers={ + "Accept": "application/json; charset=utf-8", + "Date": now, + "Signature": make_signature("get", sender, url, now), + }, + ) + except requests.RequestException: + raise ConnectorException() + if not resp.ok: + resp.raise_for_status() + try: + data = resp.json() + except ValueError: + raise ConnectorException() + + return data + + @dataclass(init=False) class Link(ActivityObject): """for tagging a book in a status""" diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 8ae93926a..0e04ffaf2 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,5 +1,6 @@ """ functionality outline for a book data connector """ from abc import ABC, abstractmethod +from urllib.parse import quote_plus import imghdr import logging import re @@ -48,7 +49,7 @@ class AbstractMinimalConnector(ABC): return f"{self.isbn_search_url}{normalized_query}" # NOTE: previously, we tried searching isbn and if that produces no results, # searched as free text. This, instead, only searches isbn if it's isbn-y - return f"{self.search_url}{query}" + return f"{self.search_url}{quote_plus(query)}" def process_search_response(self, query, data, min_confidence): """Format the search results based on the formt of the query""" @@ -244,7 +245,11 @@ def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT): raise ConnectorException(err) if not resp.ok: - raise ConnectorException() + if resp.status_code == 401: + # this is probably an AUTHORIZED_FETCH issue + resp.raise_for_status() + else: + raise ConnectorException() try: data = resp.json() except ValueError as err: diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index a9c6328fb..9361854ba 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -543,7 +543,7 @@ async def sign_and_send( headers = { "Date": now, "Digest": digest, - "Signature": make_signature(sender, destination, now, digest), + "Signature": make_signature("post", sender, destination, now, digest), "Content-Type": "application/activity+json; charset=utf-8", "User-Agent": USER_AGENT, } diff --git a/bookwyrm/models/annual_goal.py b/bookwyrm/models/annual_goal.py index 53c041141..0eefacb32 100644 --- a/bookwyrm/models/annual_goal.py +++ b/bookwyrm/models/annual_goal.py @@ -52,7 +52,7 @@ class AnnualGoal(BookWyrmModel): user=self.user, book__in=book_ids, ) - return {r.book.id: r.rating for r in reviews} + return {r.book_id: r.rating for r in reviews} @property def progress(self): diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 314b40a5c..239ec56be 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -32,7 +32,7 @@ class ReadThrough(BookWyrmModel): def save(self, *args, **kwargs): """update user active time""" - cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}") + cache.delete(f"latest_read_through-{self.user_id}-{self.book_id}") self.user.update_active_date() # an active readthrough must have an unset finish date if self.finish_date or self.stopped_date: diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 026571f62..8e754bc47 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -107,7 +107,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): # remove all caches related to all editions of this book cache.delete_many( [ - f"book-on-shelf-{book.id}-{self.shelf.id}" + f"book-on-shelf-{book.id}-{self.shelf_id}" for book in self.book.parent_work.editions.all() ] ) @@ -117,7 +117,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): if self.id and self.user.local: cache.delete_many( [ - f"book-on-shelf-{book}-{self.shelf.id}" + f"book-on-shelf-{book}-{self.shelf_id}" for book in self.book.parent_work.editions.values_list( "id", flat=True ) diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index e40609f36..35f007be2 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -72,7 +72,7 @@ class SiteSettings(SiteModel): invite_request_question = models.BooleanField(default=False) require_confirm_email = models.BooleanField(default=True) default_user_auth_group = models.ForeignKey( - auth_models.Group, null=True, blank=True, on_delete=models.PROTECT + auth_models.Group, null=True, blank=True, on_delete=models.RESTRICT ) invite_question_text = models.CharField( diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 19eab584d..bd99deee5 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -80,7 +80,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): def save(self, *args, **kwargs): """save and notify""" if self.reply_parent: - self.thread_id = self.reply_parent.thread_id or self.reply_parent.id + self.thread_id = self.reply_parent.thread_id or self.reply_parent_id super().save(*args, **kwargs) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 99aae9694..c66bd636b 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -101,6 +101,7 @@ MIDDLEWARE = [ "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", + "csp.middleware.CSPMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "bookwyrm.middleware.TimezoneMiddleware", "bookwyrm.middleware.IPBlocklistMiddleware", @@ -205,7 +206,7 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application" # redis/activity streams settings REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost") REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379) -REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None) +REDIS_ACTIVITY_PASSWORD = requests.utils.quote(env("REDIS_ACTIVITY_PASSWORD", "")) REDIS_ACTIVITY_DB_INDEX = env("REDIS_ACTIVITY_DB_INDEX", 0) REDIS_ACTIVITY_URL = env( "REDIS_ACTIVITY_URL", @@ -335,6 +336,8 @@ PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) PROTOCOL = "http" if USE_HTTPS: PROTOCOL = "https" + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True USE_S3 = env.bool("USE_S3", False) @@ -358,11 +361,17 @@ if USE_S3: MEDIA_FULL_URL = MEDIA_URL STATIC_FULL_URL = STATIC_URL DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" + CSP_DEFAULT_SRC = ("'self'", AWS_S3_CUSTOM_DOMAIN) + CSP_SCRIPT_SRC = ("'self'", AWS_S3_CUSTOM_DOMAIN) else: STATIC_URL = "/static/" MEDIA_URL = "/images/" MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}" STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}" + CSP_DEFAULT_SRC = "'self'" + CSP_SCRIPT_SRC = "'self'" + +CSP_INCLUDE_NONCE_IN = ["script-src"] OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None) OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None) @@ -373,3 +382,9 @@ TWO_FACTOR_LOGIN_MAX_SECONDS = 60 HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False) if HTTP_X_FORWARDED_PROTO: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Instance Actor for signing GET requests to "secure mode" +# Mastodon servers. +# Do not change this setting unless you already have an existing +# user with the same username - in which case you should change it! +INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py index 480891283..772d39cce 100644 --- a/bookwyrm/signatures.py +++ b/bookwyrm/signatures.py @@ -22,22 +22,26 @@ def create_key_pair(): return private_key, public_key -def make_signature(sender, destination, date, digest): +def make_signature(method, sender, destination, date, digest=None): """uses a private key to sign an outgoing message""" inbox_parts = urlparse(destination) signature_headers = [ - f"(request-target): post {inbox_parts.path}", + f"(request-target): {method} {inbox_parts.path}", f"host: {inbox_parts.netloc}", f"date: {date}", - f"digest: {digest}", ] + headers = "(request-target) host date" + if digest is not None: + signature_headers.append(f"digest: {digest}") + headers = "(request-target) host date digest" + 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"))) signature = { "keyId": f"{sender.remote_id}#main-key", "algorithm": "rsa-sha256", - "headers": "(request-target) host date digest", + "headers": headers, "signature": b64encode(signed_message).decode("utf8"), } return ",".join(f'{k}="{v}"' for (k, v) in signature.items()) diff --git a/bookwyrm/static/css/bookwyrm/overrides/_bulma_overrides.scss b/bookwyrm/static/css/bookwyrm/overrides/_bulma_overrides.scss index cdcd74202..9ab44f89d 100644 --- a/bookwyrm/static/css/bookwyrm/overrides/_bulma_overrides.scss +++ b/bookwyrm/static/css/bookwyrm/overrides/_bulma_overrides.scss @@ -40,7 +40,7 @@ } .navbar-item { - // see ../components/_details.scss :: Navbar details + /* see ../components/_details.scss :: Navbar details */ padding-right: 1.75rem; font-size: 1rem; } @@ -109,3 +109,9 @@ max-height: 35em; overflow: hidden; } + +.dropdown-menu .button { + @include mobile { + font-size: $size-6; + } +} diff --git a/bookwyrm/static/css/vendor/shepherd.scss b/bookwyrm/static/css/vendor/shepherd.scss index f8d39b782..5e84b2ea7 100644 --- a/bookwyrm/static/css/vendor/shepherd.scss +++ b/bookwyrm/static/css/vendor/shepherd.scss @@ -6,16 +6,16 @@ @use 'bulma/bulma.sass'; .shepherd-button { - @extend .button.mr-2; + @extend .button, .mr-2; } .shepherd-button.shepherd-button-secondary { - @extend .button.is-light; + @extend .button, .is-light; } .shepherd-footer { @extend .message-body; - @extend .is-info.is-light; + @extend .is-info, .is-light; border-color: $info-light; border-radius: 0 0 4px 4px; } @@ -29,7 +29,7 @@ .shepherd-text { @extend .message-body; - @extend .is-info.is-light; + @extend .is-info, .is-light; border-radius: 0; } diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 69cbb9eb8..d27c7ec54 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -46,7 +46,7 @@ - ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %}) + ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %}) {% endif %}
{% endif %} @@ -215,10 +215,10 @@ {% endif %} - {% with work=book.parent_work %} + {% with work=book.parent_work editions_count=book.parent_work.editions.count %}
- {% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
+ {% blocktrans trimmed count counter=editions_count with count=editions_count|intcomma %}
{{ count }} edition
{% plural %}
{{ count }} editions
diff --git a/bookwyrm/templates/book/series.html b/bookwyrm/templates/book/series.html
new file mode 100644
index 000000000..dc8113813
--- /dev/null
+++ b/bookwyrm/templates/book/series.html
@@ -0,0 +1,35 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load book_display_tags %}
+
+{% block title %}{{ series_name }}{% endblock %}
+
+{% block content %}
+
+ {% trans "You can set up monitoring to check if Celery is running by querying:" %}
+ {% url "settings-celery-ping" as url %}
+ {{ url }}
+ {% trans "Low priority" %} {{ queues.low_priority|intcomma }} {% trans "Medium priority" %} {{ queues.medium_priority|intcomma }} {% trans "High priority" %} {{ queues.high_priority|intcomma }} {% trans "Imports" %} {{ queues.imports|intcomma }}{{ series_name }}
+ {% trans "Queues" %}