Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow an instance to disable federation #3421

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion bookwyrm/connectors/abstract_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def get_or_create_book(self, remote_id: str) -> Optional[models.Book]:

def get_book_data(self, remote_id: str) -> JsonDict: # pylint: disable=no-self-use
"""this allows connectors to override the default behavior"""
return get_data(remote_id)
return get_data(remote_id, is_activitypub=False)

def create_edition_from_data(
self,
Expand Down Expand Up @@ -310,8 +310,13 @@ def get_data(
url: str,
params: Optional[dict[str, str]] = None,
timeout: int = settings.QUERY_TIMEOUT,
is_activitypub: bool = True,
) -> JsonDict:
"""wrapper for request.get"""
# make sure this isn't a forbidden federated request
if is_activitypub:
models.SiteSettings.raise_federation_disabled()

# check if the url is blocked
raise_not_valid_url(url)

Expand Down
4 changes: 4 additions & 0 deletions bookwyrm/connectors/bookwyrm_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
class Connector(AbstractMinimalConnector):
"""this is basically just for search"""

def __init__(self, identifier: str):
models.SiteSettings.raise_federation_disabled()
super().__init__(identifier)

def get_or_create_book(self, remote_id: str) -> models.Edition:
return activitypub.resolve_remote_id(remote_id, model=models.Edition)

Expand Down
6 changes: 5 additions & 1 deletion bookwyrm/connectors/connector_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ def first_search_result(

def get_connectors() -> Iterator[abstract_connector.AbstractConnector]:
"""load all connectors"""
for info in models.Connector.objects.filter(active=True).order_by("priority").all():
queryset = models.Connector.objects.filter(active=True)
if models.SiteSettings.get().disable_federation:
queryset = queryset.exclude(connector_file="bookwyrm_connector")

for info in queryset.order_by("priority").all():
yield load_connector(info)


Expand Down
6 changes: 3 additions & 3 deletions bookwyrm/connectors/inventaire.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def get_remote_id(self, value: str) -> str:
return f"{self.books_url}?action=by-uris&uris={value}"

def get_book_data(self, remote_id: str) -> JsonDict:
data = get_data(remote_id)
data = get_data(remote_id, is_activitypub=False)
extracted = list(data.get("entities", {}).values())
try:
data = extracted[0]
Expand Down Expand Up @@ -128,7 +128,7 @@ def load_edition_data(self, work_uri: str) -> JsonDict:
"""get a list of editions for a work"""
# pylint: disable=line-too-long
url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
return get_data(url)
return get_data(url, is_activitypub=False)

def get_edition_from_work_data(self, data: JsonDict) -> JsonDict:
work_uri = data.get("uri")
Expand Down Expand Up @@ -226,7 +226,7 @@ def get_description(self, links: JsonDict) -> str:
return ""
url = f"{self.base_url}/api/data?action=wp-extract&lang=en&title={link}"
try:
data = get_data(url)
data = get_data(url, is_activitypub=False)
except ConnectorException:
return ""
return str(data.get("extract", ""))
Expand Down
4 changes: 2 additions & 2 deletions bookwyrm/connectors/openlibrary.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ def __init__(self, identifier: str):
]

def get_book_data(self, remote_id: str) -> JsonDict:
data = get_data(remote_id)
data = get_data(remote_id, is_activitypub=False)
if data.get("type", {}).get("key") == "/type/redirect":
remote_id = self.base_url + data.get("location", "")
return get_data(remote_id)
return get_data(remote_id, is_activitypub=False)
return data

def get_remote_id_from_data(self, data: JsonDict) -> str:
Expand Down
2 changes: 1 addition & 1 deletion bookwyrm/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def site_settings(request):
if not request.is_secure():
request_protocol = "http://"

site = models.SiteSettings.objects.get()
site = models.SiteSettings.get()
theme = "css/themes/bookwyrm-light.scss"
if (
hasattr(request, "user")
Expand Down
14 changes: 14 additions & 0 deletions bookwyrm/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
""" Custom view decorators """
from functools import wraps
from bookwyrm.models.site import SiteSettings


def require_federation(function):
"""Ensure that federation is allowed before proceeding with this view"""

@wraps(function)
def wrap(request, *args, **kwargs):
SiteSettings.raise_federation_disabled()
return function(request, *args, **kwargs)

return wrap
2 changes: 1 addition & 1 deletion bookwyrm/emailing.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

def email_data():
"""fields every email needs"""
site = models.SiteSettings.objects.get()
site = models.SiteSettings.get()
return {
"site_name": site.name,
"logo": site.logo_small_url,
Expand Down
14 changes: 14 additions & 0 deletions bookwyrm/forms/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,20 @@ class Meta:
exclude = ["remote_id"]


class FederationSettings(CustomForm):
class Meta:
model = models.SiteSettings
fields = [
"disable_federation",
]

widgets = {
"disable_federation": forms.CheckboxInput(
attrs={"aria-describedby": "desc_disable_federation"}
),
}


class AutoModRuleForm(CustomForm):
class Meta:
model = models.AutoMod
Expand Down
2 changes: 1 addition & 1 deletion bookwyrm/importers/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def normalize_row(
# pylint: disable=no-self-use
def get_import_limit(self, user: User) -> tuple[int, int]:
"""check if import limit is set and return how many imports are left"""
site_settings = SiteSettings.objects.get()
site_settings = SiteSettings.get()
import_size_limit = site_settings.import_size_limit
import_limit_reset = site_settings.import_limit_reset
enforce_limit = import_size_limit and import_limit_reset
Expand Down
2 changes: 1 addition & 1 deletion bookwyrm/management/commands/admin_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

def get_admin_code():
"""get that code"""
return models.SiteSettings.objects.get().admin_code
return models.SiteSettings.get().admin_code


class Command(BaseCommand):
Expand Down
18 changes: 18 additions & 0 deletions bookwyrm/migrations/0210_sitesettings_disable_federation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2024-08-27 19:38

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("bookwyrm", "0209_user_show_ratings"),
]

operations = [
migrations.AddField(
model_name="sitesettings",
name="disable_federation",
field=models.BooleanField(default=False),
),
]
12 changes: 12 additions & 0 deletions bookwyrm/models/activitypub_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.apps import apps
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.db.models import Q
from django.utils.http import http_date
Expand Down Expand Up @@ -129,6 +130,12 @@ def find_existing(cls, data):

def broadcast(self, activity, sender, software=None, queue=BROADCAST):
"""send out an activity"""
site_model = apps.get_model("bookwyrm.SiteSettings", require_ready=True)
try:
site_model.raise_federation_disabled()
except PermissionDenied:
return

broadcast_task.apply_async(
args=(
sender.id,
Expand Down Expand Up @@ -514,6 +521,11 @@ def unfurl_related_field(related_field, sort_field=None):
@app.task(queue=BROADCAST)
def broadcast_task(sender_id: int, activity: str, recipients: list[str]):
"""the celery task for broadcast"""
# checking this here ought to be redundant unless there are already-spawned tasks
# when federation is turned off. In that case this should prevent them from running.
site_model = apps.get_model("bookwyrm.SiteSettings", require_ready=True)
site_model.raise_federation_disabled()

user_model = apps.get_model("bookwyrm.User", require_ready=True)
sender = user_model.objects.select_related("key_pair").get(id=sender_id)
asyncio.run(async_broadcast(recipients, sender, activity))
Expand Down
53 changes: 32 additions & 21 deletions bookwyrm/models/site.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
""" the particulars for this instance of BookWyrm """
from __future__ import annotations
import datetime
from typing import Optional, Iterable
from typing import Any, Optional, Iterable
from urllib.parse import urljoin
import uuid

Expand Down Expand Up @@ -31,7 +32,7 @@ class Meta:
abstract = True

# pylint: disable=no-self-use
def raise_not_editable(self, viewer):
def raise_not_editable(self, viewer: User) -> None:
"""Check if the user has the right permissions"""
if viewer.has_perm("bookwyrm.edit_instance_settings"):
return
Expand Down Expand Up @@ -83,6 +84,7 @@ class SiteSettings(SiteModel):
invite_question_text = models.CharField(
max_length=255, blank=True, default="What is your favourite book?"
)

# images
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
Expand All @@ -103,11 +105,12 @@ class SiteSettings(SiteModel):
import_limit_reset = models.IntegerField(default=0)
user_exports_enabled = models.BooleanField(default=False)
user_import_time_limit = models.IntegerField(default=48)
disable_federation = models.BooleanField(default=False)

field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])

@classmethod
def get(cls):
def get(cls) -> SiteSettings:
"""gets the site settings db entry or defaults"""
try:
return cls.objects.get(id=1)
Expand All @@ -116,29 +119,37 @@ def get(cls):
default_settings.save()
return default_settings

@classmethod
def raise_federation_disabled(cls) -> None:
"""Don't connect to the outside world"""
if cls.get().disable_federation:
raise PermissionDenied("Federation is disabled")

@property
def logo_url(self):
def logo_url(self) -> Any:
"""helper to build the logo url"""
return self.get_url("logo", "images/logo.png")

@property
def logo_small_url(self):
def logo_small_url(self) -> Any:
"""helper to build the logo url"""
return self.get_url("logo_small", "images/logo-small.png")

@property
def favicon_url(self):
def favicon_url(self) -> Any:
"""helper to build the logo url"""
return self.get_url("favicon", "images/favicon.png")

def get_url(self, field, default_path):
def get_url(self, field: str, default_path: str) -> Any:
"""get a media url or a default static path"""
uploaded = getattr(self, field, None)
if uploaded:
return get_absolute_url(uploaded)
return urljoin(STATIC_FULL_URL, default_path)

def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
def save(
self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs
) -> None:
"""if require_confirm_email is disabled, make sure no users are pending,
if enabled, make sure invite_question_text is not empty"""
if not self.invite_question_text:
Expand All @@ -161,7 +172,7 @@ class Theme(SiteModel):
path = models.CharField(max_length=50, unique=True)
loads = models.BooleanField(null=True, blank=True)

def __str__(self):
def __str__(self) -> str:
# pylint: disable=invalid-str-returned
return self.name

Expand All @@ -178,20 +189,20 @@ class SiteInvite(models.Model):
invitees = models.ManyToManyField(User, related_name="invitees")

# pylint: disable=no-self-use
def raise_not_editable(self, viewer):
def raise_not_editable(self, viewer: User) -> None:
"""Admins only"""
if viewer.has_perm("bookwyrm.create_invites"):
return
raise PermissionDenied()

def valid(self):
def valid(self) -> bool:
"""make sure it hasn't expired or been used"""
return (self.expiry is None or self.expiry > timezone.now()) and (
self.use_limit is None or self.times_used < self.use_limit
)

@property
def link(self):
def link(self) -> str:
"""formats the invite link"""
return f"{BASE_URL}/invite/{self.code}"

Expand All @@ -207,20 +218,20 @@ class InviteRequest(BookWyrmModel):
invite_sent = models.BooleanField(default=False)
ignored = models.BooleanField(default=False)

def raise_not_editable(self, viewer):
def raise_not_editable(self, viewer: User) -> None:
"""Only check perms on edit, not create"""
if not self.id or viewer.has_perm("bookwyrm.create_invites"):
return
raise PermissionDenied()

def save(self, *args, **kwargs):
def save(self, *args, **kwargs) -> None:
"""don't create a request for a registered email"""
if not self.id and User.objects.filter(email=self.email).exists():
raise IntegrityError()
super().save(*args, **kwargs)


def get_password_reset_expiry():
def get_password_reset_expiry() -> datetime.datetime:
"""give people a limited time to use the link"""
now = timezone.now()
return now + datetime.timedelta(days=1)
Expand All @@ -233,19 +244,19 @@ class PasswordReset(models.Model):
expiry = models.DateTimeField(default=get_password_reset_expiry)
user = models.OneToOneField(User, on_delete=models.CASCADE)

def valid(self):
def valid(self) -> bool:
"""make sure it hasn't expired or been used"""
return self.expiry > timezone.now()

@property
def link(self):
def link(self) -> str:
"""formats the invite link"""
return f"{BASE_URL}/password-reset/{self.code}"


# pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=SiteSettings)
def preview_image(instance, *args, **kwargs):
def preview_image(instance: SiteSettings, *args, **kwargs) -> None:
"""Update image preview for the default site image"""
if not ENABLE_PREVIEW_IMAGES:
return
Expand All @@ -256,10 +267,10 @@ def preview_image(instance, *args, **kwargs):


@app.task(queue=MISC)
def check_for_updates_task():
def check_for_updates_task() -> None:
"""See if git remote knows about a new version"""
site = SiteSettings.objects.get()
release = get_data(RELEASE_API, timeout=3)
site = SiteSettings.get()
release = get_data(RELEASE_API, timeout=3, is_activitypub=False)
available_version = release.get("tag_name", None)
if available_version:
site.available_version = available_version
Expand Down
Loading
Loading