Compare commits

...

49 commits

Author SHA1 Message Date
Matt Katz f09c26405e
Merge 98724dfa47 into c4b21ee258 2024-04-25 08:56:52 +10:00
Mouse Reeve c4b21ee258
Merge pull request #3114 from SMillerDev/feat/api/oauth
feat: add OAuth authentication
2024-04-24 15:45:54 -07:00
Mouse Reeve ad830dd885
Merge pull request #3350 from Minnozz/custom-port
Correctly handle serving BookWyrm on custom port
2024-04-24 15:27:01 -07:00
Mouse Reeve 366c647585
Merge pull request #3359 from bookwyrm-social/dependabot/pip/aiohttp-3.9.4
Bump aiohttp from 3.9.2 to 3.9.4
2024-04-24 15:13:30 -07:00
Bart Schuurmans 4f58b11330 Include the correct protocol and port in remote IDs 2024-04-24 15:35:19 +02:00
Bart Schuurmans 609bc15406 Support http:// protocol in BookWyrm connector 2024-04-24 15:30:47 +02:00
Bart Schuurmans c42db40a63 Construct absolute URLs with the correct protocol and port 2024-04-24 15:30:47 +02:00
Bart Schuurmans 3aefbb548e Allow serving BookWyrm on a non-standard port 2024-04-24 15:30:47 +02:00
Bart Schuurmans baea105c18 pytest.ini env values should be unquoted
Otherwise the quotes end up in the strings.
2024-04-24 15:30:47 +02:00
Bart Schuurmans c73d1fff6a Remove unnecessary exceptions from validate_url_domain 2024-04-24 15:30:47 +02:00
Bart Schuurmans 3d183a393f
Merge pull request #3360 from hughrun/move-fix
refactor Move for more redundancy
2024-04-24 15:30:19 +02:00
Bart Schuurmans f24fdf73b5 Update to match newer code style 2024-04-24 15:08:48 +02:00
Bart Schuurmans 839ab2fafd
Merge branch 'main' into move-fix 2024-04-24 14:56:32 +02:00
Bart Schuurmans 637f19b208
Merge pull request #3336 from Minnozz/s3-url-protocol
Support AWS_S3_URL_PROTOCOL
2024-04-24 14:53:55 +02:00
Bart Schuurmans 031223104f Clarify AWS_S3_URL_PROTOCOL in .env.example 2024-04-24 14:46:57 +02:00
Hugh Rundle 6684d60526
refactor Move for more redundancy
As outlined in #3354, a use `Move` fails if the user is moving from a BookWyrm server to another BookWrym server.
This is because:

1. the original code did not announce changes to alsoKnownAs;
2. the original code always checked the locally saved profile rather than refetching the remote data;

This commit fixes both these problems by forcing `MoveUser` to always perform a "refresh" of the local data from the remote, and by saving the user with broadcast=True when updating alsoKnownAs ids.
2024-04-22 13:35:08 +10:00
dependabot[bot] cca58023ed
Bump aiohttp from 3.9.2 to 3.9.4
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.9.2 to 3.9.4.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.9.2...v3.9.4)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-18 15:51:34 +00:00
Bart Schuurmans bf5c08dbf3 Add docker-compose.override.yml to .gitignore 2024-04-15 13:17:00 +02:00
Bart Schuurmans be872ed672 Support AWS_S3_URL_PROTOCOL
- Allow setting in .env
- Default to PROTOCOL (same as before)
- Propagate to django-storages so it generates the correct URLs in sass_src
2024-04-15 13:16:51 +02:00
mattkatz 98724dfa47 use display_name to avoid name rendering as None 2024-04-14 05:00:38 -04:00
mattkatz 74e2103e3a handle cases where edition has no author 2024-04-14 04:58:03 -04:00
mattkatz a0d15ccec0 Don't show a colon if there is no author_text
If there is no author for a book, render just the title of the book.
2024-04-14 04:55:45 -04:00
mattkatz 8dc412c4cb remove pure_name since it only applies to statuses 2024-04-14 04:48:28 -04:00
Sean Molenaar d5fb21f330
Merge branch 'main' into feat/api/oauth 2024-04-01 22:35:19 +02:00
Mouse Reeve cb3fd0cfc1
Merge branch 'main' into feat/api/oauth 2024-03-31 12:41:12 -07:00
Adeodato Simó 5340ed35de
Drop default filter in favor of per-metadata item conditionals 2024-03-19 01:08:29 -03:00
Adeodato Simó 1e14d635bc
Missing brackets and correct blocktrans usage 2024-03-19 01:07:52 -03:00
mattkatz 09d857e6fb support same identifiers as book page in rss
in book_identifiers.html template we support multiple identifiers in
order.

This commit adopts the same logic and order
2024-03-18 22:33:47 -04:00
mattkatz 1d8bd2be89 add translation usage 2024-03-18 21:38:55 -04:00
Matt Katz cd29b44807
improve shelf rss description
Co-authored-by: Adeodato Simó <73768+dato@users.noreply.github.com>
2024-03-18 21:33:30 -04:00
Matt Katz 6976cb4876
add user name to shelf rss feed title
Co-authored-by: Adeodato Simó <73768+dato@users.noreply.github.com>
2024-03-18 21:29:16 -04:00
Matt Katz 63d6486fc9
Merge branch 'main' into rss-for-shelves 2024-03-18 21:26:05 -04:00
mattkatz 5b8e083bcd use privacy__in to respect shelf privacy settings 2024-03-12 22:10:18 -04:00
mattkatz e3ca543f8e Remove extraneous function
getting context data no longer seems needed, if it ever was.
2024-03-12 22:06:31 -04:00
Matt Katz 975c3ba9aa
Correct docstring on rss feed
Co-authored-by: Adeodato Simó <73768+dato@users.noreply.github.com>
2024-03-12 21:57:28 -04:00
Matt Katz a2e41faf69
/rss shouldn't be optional in the rss route
Co-authored-by: Adeodato Simó <73768+dato@users.noreply.github.com>
2024-03-12 21:56:14 -04:00
Matt Katz 3754af7bc5
/rss shouldn't be optional in the rss route
Co-authored-by: Adeodato Simó <73768+dato@users.noreply.github.com>
2024-03-12 21:55:49 -04:00
Matt Katz 7b388ae972
Sort RSS Feed by Shelved date
This will make sure that users see books as they are shelved, which is what would be expected.

Co-authored-by: Adeodato Simó <73768+dato@users.noreply.github.com>
2024-03-12 21:43:08 -04:00
Jascha Ezra Urbach 7a25869e3f
Merge branch 'main' into rss-for-shelves 2024-01-20 12:29:02 +01:00
Sean Molenaar 5d09c54e57
Merge branch 'main' into feat/api/oauth 2023-12-07 15:38:19 +01:00
Sean Molenaar b7ba6f1a36
urls.py: fix style 2023-11-30 11:25:51 +01:00
Sean Molenaar e144ce19fa
fix: add include import from django.urls 2023-11-16 10:48:06 +01:00
Sean Molenaar da4214ad61 feat: add OAuth authentication
Issue GH-2292
2023-11-14 14:18:35 +01:00
mattkatz d3d5f1bec6 remove duplicate sitesettings error 2023-11-04 21:43:33 -04:00
mattkatz 339298cb3d Mock activitystreams add_book_statuses_task 2023-11-04 21:41:29 -04:00
mattkatz f665aea665 fixed method without docstring 2023-10-01 06:05:54 -04:00
mattkatz c142e383c9 fix linting issue
didn't really need to add the shelf to self and the linter doesn't like
seeing it outside of an init or setup.
2023-10-01 06:04:52 -04:00
mattkatz 4ae0dbde92 add a failing test for rss feeds for shelves
Currently can't get the test to succeed - it fails in an unrelated redis
error, so pushing this so I can open a draft PR to get advice on a better
test.
2023-09-28 21:50:09 -04:00
mattkatz 856737e19c working rss feed for shelves 2023-09-28 21:49:05 -04:00
53 changed files with 503 additions and 181 deletions

View file

@ -16,6 +16,11 @@ DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
# Specify when the site is served from a port that is not the default
# for the protocol (80 for HTTP or 443 for HTTPS).
# Probably only necessary in development.
# PORT=1333
MEDIA_ROOT=images/
# Database configuration
@ -78,10 +83,13 @@ S3_SIGNED_URL_EXPIRY=900
# Commented are example values if you use a non-AWS, S3-compatible service
# AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME
# non-AWS S3-compatible services will need AWS_STORAGE_BUCKET_NAME,
# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL
# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL.
# AWS_S3_URL_PROTOCOL must end in ":" and defaults to the same protocol as
# the BookWyrm instance ("http:" or "https:", based on USE_SSL).
# AWS_STORAGE_BUCKET_NAME= # "example-bucket-name"
# AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud"
# AWS_S3_URL_PROTOCOL=None # "http:"
# AWS_S3_REGION_NAME=None # "fr-par"
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
@ -136,9 +144,9 @@ HTTP_X_FORWARDED_PROTO=false
TWO_FACTOR_LOGIN_VALIDITY_WINDOW=2
TWO_FACTOR_LOGIN_MAX_SECONDS=60
# Additional hosts to allow in the Content-Security-Policy, "self" (should be DOMAIN)
# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
# Value should be a comma-separated list of host names.
# Additional hosts to allow in the Content-Security-Policy, "self" (should be
# DOMAIN with optionally ":" + PORT) and AWS_S3_CUSTOM_DOMAIN (if used) are
# added by default. Value should be a comma-separated list of host names.
CSP_ADDITIONAL_HOSTS=
# Time before being logged out (in seconds)

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ nginx/default.conf
#macOS
**/.DS_Store
# Docker
docker-compose.override.yml

View file

@ -118,9 +118,11 @@ def get_connectors() -> Iterator[abstract_connector.AbstractConnector]:
def get_or_create_connector(remote_id: str) -> abstract_connector.AbstractConnector:
"""get the connector related to the object's server"""
url = urlparse(remote_id)
identifier = url.netloc
identifier = url.hostname
if not identifier:
raise ValueError("Invalid remote id")
raise ValueError(f"Invalid remote id: {remote_id}")
base_url = f"{url.scheme}://{url.netloc}"
try:
connector_info = models.Connector.objects.get(identifier=identifier)
@ -128,10 +130,10 @@ def get_or_create_connector(remote_id: str) -> abstract_connector.AbstractConnec
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file="bookwyrm_connector",
base_url=f"https://{identifier}",
books_url=f"https://{identifier}/book",
covers_url=f"https://{identifier}/images/covers",
search_url=f"https://{identifier}/search?q=",
base_url=base_url,
books_url=f"{base_url}/book",
covers_url=f"{base_url}/images/covers",
search_url=f"{base_url}/search?q=",
priority=2,
)
@ -188,8 +190,11 @@ def raise_not_valid_url(url: str) -> None:
if not parsed.scheme in ["http", "https"]:
raise ConnectorException("Invalid scheme: ", url)
if not parsed.hostname:
raise ConnectorException("Hostname missing: ", url)
try:
ipaddress.ip_address(parsed.netloc)
ipaddress.ip_address(parsed.hostname)
raise ConnectorException("Provided url is an IP address: ", url)
except ValueError:
# it's not an IP address, which is good

View file

@ -4,7 +4,7 @@ from django.template.loader import get_template
from bookwyrm import models, settings
from bookwyrm.tasks import app, EMAIL
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import DOMAIN, BASE_URL
def email_data():
@ -14,6 +14,7 @@ def email_data():
"site_name": site.name,
"logo": site.logo_small_url,
"domain": DOMAIN,
"base_url": BASE_URL,
"user": None,
}

View file

@ -26,7 +26,7 @@ class FileLinkForm(CustomForm):
url = cleaned_data.get("url")
filetype = cleaned_data.get("filetype")
book = cleaned_data.get("book")
domain = urlparse(url).netloc
domain = urlparse(url).hostname
if models.LinkDomain.objects.filter(domain=domain).exists():
status = models.LinkDomain.objects.get(domain=domain).status
if status == "blocked":

View file

@ -8,7 +8,7 @@ from django.contrib.postgres.indexes import GinIndex
import pgtrigger
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
from bookwyrm.utils.db import format_trigger
from .book import BookDataModel, MergedAuthor
@ -70,7 +70,7 @@ class Author(BookDataModel):
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/author/{self.id}"
return f"{BASE_URL}/author/{self.id}"
class Meta:
"""sets up indexes and triggers"""

View file

@ -10,7 +10,7 @@ from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
from .fields import RemoteIdField
@ -38,7 +38,7 @@ class BookWyrmModel(models.Model):
def get_remote_id(self):
"""generate the url that resolves to the local object, without a slug"""
base_path = f"https://{DOMAIN}"
base_path = BASE_URL
if hasattr(self, "user"):
base_path = f"{base_path}{self.user.local_path}"
@ -53,7 +53,7 @@ class BookWyrmModel(models.Model):
@property
def local_path(self):
"""how to link to this object in the local app, with a slug"""
local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
local = self.get_remote_id().replace(BASE_URL, "")
name = None
if hasattr(self, "name_field"):

View file

@ -21,7 +21,7 @@ from bookwyrm import activitypub
from bookwyrm.isbn.isbn import hyphenator_singleton as hyphenator
from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import (
DOMAIN,
BASE_URL,
DEFAULT_LANGUAGE,
LANGUAGE_ARTICLES,
ENABLE_PREVIEW_IMAGES,
@ -327,7 +327,7 @@ class Book(BookDataModel):
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/book/{self.id}"
return f"{BASE_URL}/book/{self.id}"
def guess_sort_title(self):
"""Get a best-guess sort title for the current book"""

View file

@ -11,7 +11,7 @@ ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
class Connector(BookWyrmModel):
"""book data source connectors"""
identifier = models.CharField(max_length=255, unique=True)
identifier = models.CharField(max_length=255, unique=True) # domain
priority = models.IntegerField(default=2)
name = models.CharField(max_length=255, null=True, blank=True)
connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)

View file

@ -16,7 +16,7 @@ FederationStatus = [
class FederatedServer(BookWyrmModel):
"""store which servers we federate with"""
server_name = models.CharField(max_length=255, unique=True)
server_name = models.CharField(max_length=255, unique=True) # domain
status = models.CharField(
max_length=255, default="federated", choices=FederationStatus
)
@ -64,5 +64,4 @@ class FederatedServer(BookWyrmModel):
def is_blocked(cls, url: str) -> bool:
"""look up if a domain is blocked"""
url = urlparse(url)
domain = url.netloc
return cls.objects.filter(server_name=domain, status="blocked").exists()
return cls.objects.filter(server_name=url.hostname, status="blocked").exists()

View file

@ -1,7 +1,7 @@
""" do book related things with other users """
from django.db import models, IntegrityError, transaction
from django.db.models import Q
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
from .base_model import BookWyrmModel
from . import fields
from .relationship import UserBlocks
@ -17,7 +17,7 @@ class Group(BookWyrmModel):
def get_remote_id(self):
"""don't want the user to be in there in this case"""
return f"https://{DOMAIN}/group/{self.id}"
return f"{BASE_URL}/group/{self.id}"
@classmethod
def followers_filter(cls, queryset, viewer):

View file

@ -38,7 +38,7 @@ class Link(ActivitypubMixin, BookWyrmModel):
"""create a link"""
# get or create the associated domain
if not self.domain:
domain = urlparse(self.url).netloc
domain = urlparse(self.url).hostname
self.domain, _ = LinkDomain.objects.get_or_create(domain=domain)
# this is never broadcast, the owning model broadcasts an update

View file

@ -7,7 +7,7 @@ from django.db.models import Q
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
@ -50,7 +50,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
def get_remote_id(self):
"""don't want the user to be in there in this case"""
return f"https://{DOMAIN}/list/{self.id}"
return f"{BASE_URL}/list/{self.id}"
@property
def collection_queryset(self):

View file

@ -10,7 +10,7 @@ from .notification import Notification, NotificationType
class Move(ActivityMixin, BookWyrmModel):
"""migrating an activitypub user account"""
"""migrating an activitypub object"""
user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor"

View file

@ -3,7 +3,7 @@ from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils.translation import gettext_lazy as _
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
from .base_model import BookWyrmModel
@ -46,7 +46,7 @@ class Report(BookWyrmModel):
raise PermissionDenied()
def get_remote_id(self):
return f"https://{DOMAIN}/settings/reports/{self.id}"
return f"{BASE_URL}/settings/reports/{self.id}"
def comment(self, user, note):
"""comment on a report"""

View file

@ -6,7 +6,7 @@ from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
from bookwyrm.tasks import BROADCAST
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
@ -71,7 +71,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
@property
def local_path(self):
"""No slugs"""
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
return self.get_remote_id().replace(BASE_URL, "")
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""

View file

@ -12,7 +12,7 @@ from model_utils import FieldTracker
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from bookwyrm.settings import RELEASE_API
from bookwyrm.tasks import app, MISC
from .base_model import BookWyrmModel, new_access_code
@ -188,7 +188,7 @@ class SiteInvite(models.Model):
@property
def link(self):
"""formats the invite link"""
return f"https://{DOMAIN}/invite/{self.code}"
return f"{BASE_URL}/invite/{self.code}"
class InviteRequest(BookWyrmModel):
@ -235,7 +235,7 @@ class PasswordReset(models.Model):
@property
def link(self):
"""formats the invite link"""
return f"https://{DOMAIN}/password-reset/{self.code}"
return f"{BASE_URL}/password-reset/{self.code}"
# pylint: disable=unused-argument

View file

@ -19,7 +19,7 @@ from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status
from bookwyrm.preview_images import generate_user_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, LANGUAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app, MISC
from bookwyrm.utils import regex
@ -42,12 +42,6 @@ def get_feed_filter_choices():
return [f[0] for f in FeedFilterChoices]
def site_link():
"""helper for generating links to the site"""
protocol = "https" if USE_HTTPS else "http"
return f"{protocol}://{DOMAIN}"
# pylint: disable=too-many-public-methods
class User(OrderedCollectionPageMixin, AbstractUser):
"""a user who wants to read books"""
@ -214,8 +208,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
@property
def confirmation_link(self):
"""helper for generating confirmation links"""
link = site_link()
return f"{link}/confirm-email/{self.confirmation_code}"
return f"{BASE_URL}/confirm-email/{self.confirmation_code}"
@property
def following_link(self):
@ -349,7 +342,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = f"{self.username}@{actor_parts.netloc}"
self.username = f"{self.username}@{actor_parts.hostname}"
# this user already exists, no need to populate fields
if not created:
@ -369,11 +362,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
with transaction.atomic():
# populate fields for local users
link = site_link()
self.remote_id = f"{link}/user/{self.localname}"
self.remote_id = f"{BASE_URL}/user/{self.localname}"
self.followers_url = f"{self.remote_id}/followers"
self.inbox = f"{self.remote_id}/inbox"
self.shared_inbox = f"{link}/inbox"
self.shared_inbox = f"{BASE_URL}/inbox"
self.outbox = f"{self.remote_id}/outbox"
# an id needs to be set before we can proceed with related models
@ -558,7 +550,7 @@ def set_remote_server(user_id, allow_external_connections=False):
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
federated_server = get_or_create_remote_server(
actor_parts.netloc, allow_external_connections=allow_external_connections
actor_parts.hostname, 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:

View file

@ -101,6 +101,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"oauth2_provider",
"file_resubmit",
"sass_processor",
"bookwyrm",
@ -350,28 +351,31 @@ USE_L10N = True
USE_TZ = True
USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
# Imagekit generated thumbnails
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
IMAGEKIT_CACHEFILE_DIR = "thumbnails"
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = "bookwyrm.thumbnail_generation.Strategy"
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
CSP_ADDITIONAL_HOSTS = env.list("CSP_ADDITIONAL_HOSTS", [])
# Storage
PROTOCOL = "http"
if USE_HTTPS:
PROTOCOL = "https"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
PORT = env.int("PORT", 443 if USE_HTTPS else 80)
if (USE_HTTPS and PORT == 443) or (not USE_HTTPS and PORT == 80):
NETLOC = DOMAIN
else:
NETLOC = f"{DOMAIN}:{PORT}"
BASE_URL = f"{PROTOCOL}://{NETLOC}"
USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +{BASE_URL})"
# Storage
USE_S3 = env.bool("USE_S3", False)
USE_AZURE = env.bool("USE_AZURE", False)
S3_SIGNED_URL_EXPIRY = env.int("S3_SIGNED_URL_EXPIRY", 900)
@ -386,21 +390,32 @@ if USE_S3:
AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", None)
AWS_DEFAULT_ACL = "public-read"
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
AWS_S3_URL_PROTOCOL = env("AWS_S3_URL_PROTOCOL", f"{PROTOCOL}:")
# S3 Static settings
STATIC_LOCATION = "static"
STATIC_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
STATIC_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
STATIC_FULL_URL = STATIC_URL
STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
# S3 Media settings
MEDIA_LOCATION = "images"
MEDIA_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
MEDIA_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
MEDIA_FULL_URL = MEDIA_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
# S3 Exports settings
EXPORTS_STORAGE = "bookwyrm.storage_backends.ExportsS3Storage"
# Content Security Policy
CSP_DEFAULT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
CSP_DEFAULT_SRC = [
"'self'",
f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}"
if AWS_S3_CUSTOM_DOMAIN
else None,
] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = [
"'self'",
f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}"
if AWS_S3_CUSTOM_DOMAIN
else None,
] + CSP_ADDITIONAL_HOSTS
elif USE_AZURE:
# Azure settings
AZURE_ACCOUNT_NAME = env("AZURE_ACCOUNT_NAME")
@ -429,11 +444,11 @@ elif USE_AZURE:
else:
# Static settings
STATIC_URL = "/static/"
STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
STATIC_FULL_URL = BASE_URL + STATIC_URL
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
# Media settings
MEDIA_URL = "/images/"
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
MEDIA_FULL_URL = BASE_URL + MEDIA_URL
DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"
# Exports settings
EXPORTS_STORAGE = "bookwyrm.storage_backends.ExportsFileStorage"

View file

@ -2,10 +2,10 @@
<div style="font-family: BlinkMacSystemFont,-apple-system,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif; border-radius: 6px; background-color: #efefef; max-width: 632px;">
<div style="padding: 1rem; overflow: auto;">
<div style="float: left; margin-right: 1rem;">
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="{{ logo }}" alt="logo" loading="lazy" decoding="async"></a>
<a style="color: #3273dc;" href="{{ base_url }}" style="text-decoration: none;"><img src="{{ logo }}" alt="logo" loading="lazy" decoding="async"></a>
</div>
<div>
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>
<a style="color: black; text-decoration: none" href="{{ base_url }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>
{{ domain }}</a>
</div>
</div>
@ -18,9 +18,9 @@
</div>
<div style="padding: 1rem; font-size: 0.8rem;">
<p style="margin: 0; color: #333;">{% blocktrans %}BookWyrm hosted on <a style="color: #3273dc;" href="https://{{ domain }}">{{ site_name }}</a>{% endblocktrans %}</p>
<p style="margin: 0; color: #333;">{% blocktrans %}BookWyrm hosted on <a style="color: #3273dc;" href="{{ base_url }}">{{ site_name }}</a>{% endblocktrans %}</p>
{% if user %}
<p style="margin: 0; color: #333;"><a style="color: #3273dc;" href="https://{{ domain }}{% url 'prefs-profile' %}">{% trans "Email preference" %}</a></p>
<p style="margin: 0; color: #333;"><a style="color: #3273dc;" href="{{ base_url }}{% url 'prefs-profile' %}">{% trans "Email preference" %}</a></p>
{% endif %}
</div>
</div>

View file

@ -12,6 +12,6 @@
<p>
{% url 'code-of-conduct' as coc_path %}
{% url 'about' as about_path %}
{% blocktrans %}Learn more <a href="https://{{ domain }}{{ about_path }}">about {{ site_name }}</a>.{% endblocktrans %}
{% blocktrans %}Learn more <a href="{{ base_url }}{{ about_path }}">about {{ site_name }}</a>.{% endblocktrans %}
</p>
{% endblock %}

View file

@ -5,6 +5,6 @@
{{ invite_link }}
{% blocktrans %}Learn more about {{ site_name }}:{% endblocktrans %} https://{{ domain }}{% url 'about' %}
{% blocktrans %}Learn more about {{ site_name }}:{% endblocktrans %} {{ base_url }}{% url 'about' %}
{% endblock %}

View file

@ -10,6 +10,6 @@
<Image width="16" height="16" type="image/x-icon">{{ image }}</Image>
<Url
type="text/html"
template="https://{{ DOMAIN }}{% url 'search' %}?q={searchTerms}"
template="{{ BASE_URL }}{% url 'search' %}?q={searchTerms}"
/>
</OpenSearchDescription>

View file

@ -0,0 +1,11 @@
{% load i18n %}
{% blocktrans trimmed with book_title=obj.title book_author=obj.author_text %}
{{ book_title }}{% if book_author %} by {{ book_author }}{% endif %}
{% endblocktrans %}
{{obj.description|default:""}}
{% if obj.isbn_13 %}{% trans "ISBN 13:" %} {{ obj.isbn_13 }}{% endif %}
{% if obj.oclc_number %}{% trans "OCLC Number:" %} {{ obj.oclc_number }}{% endif %}
{% if obj.asin %}{% trans "ASIN:" %} {{ obj.asin }}{% endif %}
{% if obj.aasin %}{% trans "Audible ASIN:" %} {{ obj.aasin }}{% endif %}
{% if obj.isfdb %}{% trans "ISFDB ID:" %} {{ obj.isfdb }}{% endif %}
{% if obj.goodreads_key %}{% trans "Goodreads:" %} {{ obj.goodreads_key }}{% endif %}

View file

@ -120,7 +120,7 @@ def id_to_username(user_id):
"""given an arbitrary remote id, return the username"""
if user_id:
url = urlparse(user_id)
domain = url.netloc
domain = url.hostname
parts = url.path.split("/")
name = parts[-1]
value = f"{name}@{domain}"

View file

@ -6,7 +6,7 @@ import responses
from bookwyrm import models
from bookwyrm.connectors import abstract_connector, ConnectorException
from bookwyrm.connectors.abstract_connector import Mapping, get_data
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
class AbstractConnector(TestCase):
@ -86,7 +86,7 @@ class AbstractConnector(TestCase):
def test_get_or_create_book_existing(self):
"""find an existing book by remote/origin id"""
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(self.book.remote_id, f"https://{DOMAIN}/book/{self.book.id}")
self.assertEqual(self.book.remote_id, f"{BASE_URL}/book/{self.book.id}")
self.assertEqual(self.book.origin_id, "https://example.com/book/1234")
# dedupe by origin id
@ -95,9 +95,7 @@ class AbstractConnector(TestCase):
self.assertEqual(result, self.book)
# dedupe by remote id
result = self.connector.get_or_create_book(
f"https://{DOMAIN}/book/{self.book.id}"
)
result = self.connector.get_or_create_book(f"{BASE_URL}/book/{self.book.id}")
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(result, self.book)

View file

@ -0,0 +1,42 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "https://example.com/user/mouse",
"type": "Person",
"preferredUsername": "mouse",
"name": "MOUSE?? MOUSE!!",
"inbox": "https://example.com/user/mouse/inbox",
"outbox": "https://example.com/user/mouse/outbox",
"followers": "https://example.com/user/mouse/followers",
"following": "https://example.com/user/mouse/following",
"summary": "",
"publicKey": {
"id": "https://example.com/user/mouse/#main-key",
"owner": "https://example.com/user/mouse",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC6QisDrjOQvkRo/MqNmSYPwqtt\nCxg/8rCW+9jKbFUKvqjTeKVotEE85122v/DCvobCCdfQuYIFdVMk+dB1xJ0iPGPg\nyU79QHY22NdV9mFKA2qtXVVxb5cxpA4PlwOHM6PM/k8B+H09OUrop2aPUAYwy+vg\n+MXyz8bAXrIS1kq6fQIDAQAB\n-----END PUBLIC KEY-----"
},
"endpoints": {
"sharedInbox": "https://example.com/inbox"
},
"bookwyrmUser": true,
"manuallyApprovesFollowers": false,
"discoverable": false,
"alsoKnownAs": [
"https://your.domain.here:4242/user/rat"
],
"devices": "https://friend.camp/users/tripofmice/collections/devices",
"tag": [],
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/images/avatars/AL-2-crop-50.png"
}
}

View file

@ -214,7 +214,7 @@
"attributedTo": "https://www.example.com//user/rat",
"content": "<p>I like it</p>",
"to": [
"https://your.domain.here/user/rat/followers"
"https://your.domain.here:4242/user/rat/followers"
],
"cc": [],
"replies": {
@ -395,7 +395,7 @@
"https://local.lists/9999"
],
"follows": [
"https://your.domain.here/user/rat"
"https://your.domain.here:4242/user/rat"
],
"blocks": ["https://your.domain.here/user/badger"]
"blocks": ["https://your.domain.here:4242/user/badger"]
}

View file

@ -5,7 +5,7 @@ from django.test import TestCase
from bookwyrm import models
from bookwyrm.models import base_model
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
# pylint: disable=attribute-defined-outside-init
@ -44,14 +44,14 @@ class BaseModel(TestCase):
"""these should be generated"""
self.test_model.id = 1 # pylint: disable=invalid-name
expected = self.test_model.get_remote_id()
self.assertEqual(expected, f"https://{DOMAIN}/bookwyrmtestmodel/1")
self.assertEqual(expected, f"{BASE_URL}/bookwyrmtestmodel/1")
def test_remote_id_with_user(self):
"""format of remote id when there's a user object"""
self.test_model.user = self.local_user
self.test_model.id = 1
expected = self.test_model.get_remote_id()
self.assertEqual(expected, f"https://{DOMAIN}/user/mouse/bookwyrmtestmodel/1")
self.assertEqual(expected, f"{BASE_URL}/user/mouse/bookwyrmtestmodel/1")
def test_set_remote_id(self):
"""this function sets remote ids after creation"""
@ -60,7 +60,7 @@ class BaseModel(TestCase):
instance = models.Work.objects.create(title="work title")
instance.remote_id = None
base_model.set_remote_id(None, instance, True)
self.assertEqual(instance.remote_id, f"https://{DOMAIN}/book/{instance.id}")
self.assertEqual(instance.remote_id, f"{BASE_URL}/book/{instance.id}")
# shouldn't set remote_id if it's not created
instance.remote_id = None

View file

@ -31,7 +31,7 @@ class Book(TestCase):
def test_remote_id(self):
"""fanciness with remote/origin ids"""
remote_id = f"https://{settings.DOMAIN}/book/{self.work.id}"
remote_id = f"{settings.BASE_URL}/book/{self.work.id}"
self.assertEqual(self.work.get_remote_id(), remote_id)
self.assertEqual(self.work.remote_id, remote_id)

View file

@ -4,7 +4,7 @@ from dataclasses import dataclass
import datetime
import json
import pathlib
import re
from urllib.parse import urlparse
from typing import List
from unittest import expectedFailure
from unittest.mock import patch
@ -22,7 +22,7 @@ from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm.models import fields, User, Status, Edition
from bookwyrm.models.base_model import BookWyrmModel
from bookwyrm.models.activitypub_mixin import ActivitypubMixin
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import PROTOCOL, NETLOC
# pylint: disable=too-many-public-methods
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@ -427,12 +427,10 @@ class ModelFields(TestCase):
instance = fields.ImageField()
output = instance.field_to_activity(user.avatar)
self.assertIsNotNone(
re.match(
rf"https:\/\/{DOMAIN}\/.*\.jpg",
output.url,
)
)
parsed_url = urlparse(output.url)
self.assertEqual(parsed_url.scheme, PROTOCOL)
self.assertEqual(parsed_url.netloc, NETLOC)
self.assertRegex(parsed_url.path, r"\.jpg$")
self.assertEqual(output.name, "")
self.assertEqual(output.type, "Image")

View file

@ -28,7 +28,7 @@ class List(TestCase):
def test_remote_id(self, *_):
"""shelves use custom remote ids"""
book_list = models.List.objects.create(name="Test List", user=self.local_user)
expected_id = f"https://{settings.DOMAIN}/list/{book_list.id}"
expected_id = f"{settings.BASE_URL}/list/{book_list.id}"
self.assertEqual(book_list.get_remote_id(), expected_id)
def test_to_activity(self, *_):

View file

@ -0,0 +1,60 @@
""" testing move models """
from unittest.mock import patch
from django.core.exceptions import PermissionDenied
from django.test import TestCase
from bookwyrm import models
class MoveUser(TestCase):
"""move your account to another identity"""
@classmethod
def setUpTestData(cls):
"""we need some users for this"""
with patch("bookwyrm.models.user.set_remote_server.delay"):
cls.target_user = models.User.objects.create_user(
"rat",
"rat@rat.com",
"ratword",
local=False,
remote_id="https://example.com/users/rat",
inbox="https://example.com/users/rat/inbox",
outbox="https://example.com/users/rat/outbox",
)
with (
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
):
cls.origin_user = models.User.objects.create_user(
"mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse"
)
cls.origin_user.remote_id = "http://local.com/user/mouse"
cls.origin_user.save(broadcast=False, update_fields=["remote_id"])
def test_user_move_unauthorized(self):
"""attempt a user move without alsoKnownAs set"""
with self.assertRaises(PermissionDenied):
models.MoveUser.objects.create(
user=self.origin_user,
object=self.origin_user.remote_id,
target=self.target_user,
)
@patch("bookwyrm.suggested_users.remove_user_task.delay")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_user_move(self, *_):
"""move user"""
self.target_user.also_known_as.add(self.origin_user.id)
self.target_user.save(broadcast=False)
models.MoveUser.objects.create(
user=self.origin_user,
object=self.origin_user.remote_id,
target=self.target_user,
)
self.assertEqual(self.origin_user.moved_to, self.target_user.remote_id)

View file

@ -35,7 +35,7 @@ class Shelf(TestCase):
shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
expected_id = f"https://{settings.DOMAIN}/user/mouse/books/test-shelf"
expected_id = f"{settings.BASE_URL}/user/mouse/books/test-shelf"
self.assertEqual(shelf.get_remote_id(), expected_id)
def test_to_activity(self, *_):

View file

@ -79,7 +79,7 @@ class SiteModels(TestCase):
def test_site_invite_link(self):
"""invite link generator"""
invite = models.SiteInvite.objects.create(user=self.local_user, code="hello")
self.assertEqual(invite.link, f"https://{settings.DOMAIN}/invite/hello")
self.assertEqual(invite.link, f"{settings.BASE_URL}/invite/hello")
def test_invite_request(self):
"""someone wants an invite"""
@ -95,7 +95,7 @@ class SiteModels(TestCase):
"""password reset token"""
token = models.PasswordReset.objects.create(user=self.local_user, code="hello")
self.assertTrue(token.valid())
self.assertEqual(token.link, f"https://{settings.DOMAIN}/password-reset/hello")
self.assertEqual(token.link, f"{settings.BASE_URL}/password-reset/hello")
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.suggested_users.remove_user_task.delay")

View file

@ -60,7 +60,7 @@ class Status(TestCase):
def test_status_generated_fields(self, *_):
"""setting remote id"""
status = models.Status.objects.create(content="bleh", user=self.local_user)
expected_id = f"https://{settings.DOMAIN}/user/mouse/status/{status.id}"
expected_id = f"{settings.BASE_URL}/user/mouse/status/{status.id}"
self.assertEqual(status.remote_id, expected_id)
self.assertEqual(status.privacy, "public")
@ -151,7 +151,7 @@ class Status(TestCase):
self.assertEqual(activity["tag"][0]["type"], "Hashtag")
self.assertEqual(activity["tag"][0]["name"], "#content")
self.assertEqual(
activity["tag"][0]["href"], f"https://{settings.DOMAIN}/hashtag/{tag.id}"
activity["tag"][0]["href"], f"{settings.BASE_URL}/hashtag/{tag.id}"
)
def test_status_with_mention_to_activity(self, *_):
@ -227,11 +227,9 @@ class Status(TestCase):
self.assertEqual(activity["sensitive"], False)
self.assertIsInstance(activity["attachment"], list)
self.assertEqual(activity["attachment"][0]["type"], "Document")
self.assertTrue(
re.match(
r"https:\/\/your.domain.here\/images\/covers\/test(_[A-z0-9]+)?.jpg",
activity["attachment"][0]["url"],
)
self.assertRegex(
activity["attachment"][0]["url"],
rf"^{settings.BASE_URL}/images/covers/test(_[A-z0-9]+)?.jpg$",
)
self.assertEqual(activity["attachment"][0]["name"], "Test Edition")
@ -263,12 +261,10 @@ class Status(TestCase):
),
)
self.assertEqual(activity["attachment"][0]["type"], "Document")
# self.assertTrue(
# re.match(
# r"https:\/\/your.domain.here\/images\/covers\/test_[A-z0-9]+.jpg",
# activity["attachment"][0].url,
# )
# )
self.assertRegex(
activity["attachment"][0]["url"],
rf"^{settings.BASE_URL}/images/covers/test_[A-z0-9]+.jpg$",
)
self.assertEqual(activity["attachment"][0]["name"], "Test Edition")
def test_quotation_to_activity(self, *_):
@ -306,11 +302,9 @@ class Status(TestCase):
),
)
self.assertEqual(activity["attachment"][0]["type"], "Document")
self.assertTrue(
re.match(
r"https:\/\/your.domain.here\/images\/covers\/test(_[A-z0-9]+)?.jpg",
activity["attachment"][0]["url"],
)
self.assertRegex(
activity["attachment"][0]["url"],
rf"^{settings.BASE_URL}/images/covers/test(_[A-z0-9]+)?.jpg$",
)
self.assertEqual(activity["attachment"][0]["name"], "Test Edition")
@ -400,11 +394,9 @@ class Status(TestCase):
)
self.assertEqual(activity["content"], "test content")
self.assertEqual(activity["attachment"][0]["type"], "Document")
self.assertTrue(
re.match(
r"https:\/\/your.domain.here\/images\/covers\/test_[A-z0-9]+.jpg",
activity["attachment"][0]["url"],
)
self.assertRegex(
activity["attachment"][0]["url"],
rf"^{settings.BASE_URL}/images/covers/test_[A-z0-9]+.jpg$",
)
self.assertEqual(activity["attachment"][0]["name"], "Test Edition")
@ -425,11 +417,9 @@ class Status(TestCase):
)
self.assertEqual(activity["content"], "test content")
self.assertEqual(activity["attachment"][0]["type"], "Document")
self.assertTrue(
re.match(
r"https:\/\/your.domain.here\/images\/covers\/test_[A-z0-9]+.jpg",
activity["attachment"][0]["url"],
)
self.assertRegex(
activity["attachment"][0]["url"],
rf"^{settings.BASE_URL}/images/covers/test_[A-z0-9]+.jpg$",
)
self.assertEqual(activity["attachment"][0]["name"], "Test Edition")
@ -448,11 +438,9 @@ class Status(TestCase):
f'rated <em><a href="{self.book.remote_id}">{self.book.title}</a></em>: 3 stars',
)
self.assertEqual(activity["attachment"][0]["type"], "Document")
self.assertTrue(
re.match(
r"https:\/\/your.domain.here\/images\/covers\/test_[A-z0-9]+.jpg",
activity["attachment"][0]["url"],
)
self.assertRegex(
activity["attachment"][0]["url"],
rf"^{settings.BASE_URL}/images/covers/test_[A-z0-9]+.jpg$",
)
self.assertEqual(activity["attachment"][0]["name"], "Test Edition")

View file

@ -9,15 +9,12 @@ import responses
from bookwyrm import models
from bookwyrm.management.commands import initdb
from bookwyrm.settings import USE_HTTPS, DOMAIN
from bookwyrm.settings import DOMAIN, BASE_URL
# pylint: disable=missing-class-docstring
# pylint: disable=missing-function-docstring
class User(TestCase):
protocol = "https://" if USE_HTTPS else "http://"
@classmethod
def setUpTestData(cls):
with (
@ -49,11 +46,11 @@ class User(TestCase):
def test_computed_fields(self):
"""username instead of id here"""
expected_id = f"{self.protocol}{DOMAIN}/user/mouse"
expected_id = f"{BASE_URL}/user/mouse"
self.assertEqual(self.user.remote_id, expected_id)
self.assertEqual(self.user.username, f"mouse@{DOMAIN}")
self.assertEqual(self.user.localname, "mouse")
self.assertEqual(self.user.shared_inbox, f"{self.protocol}{DOMAIN}/inbox")
self.assertEqual(self.user.shared_inbox, f"{BASE_URL}/inbox")
self.assertEqual(self.user.inbox, f"{expected_id}/inbox")
self.assertEqual(self.user.outbox, f"{expected_id}/outbox")
self.assertEqual(self.user.followers_url, f"{expected_id}/followers")
@ -130,7 +127,7 @@ class User(TestCase):
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
):
user = models.User.objects.create_user(
f"test2{DOMAIN}",
"test2",
"test2@bookwyrm.test",
localname="test2",
**user_attrs,
@ -145,7 +142,7 @@ class User(TestCase):
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
):
user = models.User.objects.create_user(
f"test1{DOMAIN}",
"test1",
"test1@bookwyrm.test",
localname="test1",
**user_attrs,

View file

@ -15,7 +15,7 @@ from django.utils.http import http_date
from bookwyrm import models
from bookwyrm.activitypub import Follow
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import DOMAIN, NETLOC
from bookwyrm.signatures import create_key_pair, make_signature, make_digest
@ -77,7 +77,7 @@ class Signature(TestCase):
"HTTP_SIGNATURE": signature,
"HTTP_DIGEST": digest,
"HTTP_CONTENT_TYPE": "application/activity+json; charset=utf-8",
"HTTP_HOST": DOMAIN,
"HTTP_HOST": NETLOC,
},
)

View file

@ -2,6 +2,7 @@
import re
from django.test import TestCase
from bookwyrm.settings import BASE_URL
from bookwyrm.utils import regex
from bookwyrm.utils.validate import validate_url_domain
@ -15,17 +16,11 @@ class TestUtils(TestCase):
def test_valid_url_domain(self):
"""Check with a valid URL"""
self.assertEqual(
validate_url_domain("https://your.domain.here/legit-book-url/"),
"https://your.domain.here/legit-book-url/",
)
legit = f"{BASE_URL}/legit-book-url/"
self.assertEqual(validate_url_domain(legit), legit)
def test_invalid_url_domain(self):
"""Check with an invalid URL"""
self.assertIsNone(
validate_url_domain("https://up-to-no-good.tld/bad-actor.exe")
)
def test_default_url_domain(self):
"""Check with a default URL"""
self.assertEqual(validate_url_domain("/"), "/")

View file

@ -0,0 +1,114 @@
""" test move functionality """
import json
from unittest.mock import patch
import pathlib
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase
from django.test.client import RequestFactory
import responses
from bookwyrm import forms, models, views
@patch("bookwyrm.activitystreams.add_status_task.delay")
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.suggested_users.rerank_user_task.delay")
class ViewsHelpers(TestCase):
"""viewing and creating statuses"""
@classmethod
def setUpTestData(cls):
"""we need basic test data and mocks"""
with (
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
patch("bookwyrm.suggested_users.rerank_user_task.delay"),
):
cls.local_user = models.User.objects.create_user(
"rat",
"rat@rat.com",
"ratword",
local=True,
discoverable=True,
localname="rat",
)
with (
patch("bookwyrm.models.user.set_remote_server.delay"),
patch("bookwyrm.suggested_users.rerank_user_task.delay"),
):
cls.remote_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=False,
remote_id="https://example.com/user/mouse",
)
def setUp(self):
"""individual test setup"""
self.factory = RequestFactory()
datafile = pathlib.Path(__file__).parent.joinpath(
"../../data/ap_user_move.json"
)
self.userdata = json.loads(datafile.read_bytes())
del self.userdata["icon"]
@responses.activate
@patch("bookwyrm.models.user.set_remote_server.delay")
@patch("bookwyrm.suggested_users.remove_user_task.delay")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_move_user_view(self, *_):
"""move user"""
self.assertEqual(self.remote_user.remote_id, "https://example.com/user/mouse")
self.assertIsNone(self.local_user.moved_to)
self.assertIsNone(self.remote_user.moved_to)
self.assertIsNone(self.local_user.also_known_as.first())
self.assertIsNone(self.remote_user.also_known_as.first())
username = "mouse@example.com"
wellknown = {
"subject": "acct:mouse@example.com",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/user/mouse",
}
],
}
responses.add(
responses.GET,
f"https://example.com/.well-known/webfinger?resource=acct:{username}",
json=wellknown,
status=200,
)
responses.add(
responses.GET,
"https://example.com/user/mouse",
json=self.userdata,
status=200,
)
view = views.MoveUser.as_view()
form = forms.MoveUserForm()
form.data["target"] = "mouse@example.com"
form.data["password"] = "ratword"
request = self.factory.post("", form.data)
request.user = self.local_user
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
view(request)
self.local_user.refresh_from_db()
self.assertEqual(self.local_user.also_known_as.first(), self.remote_user)
self.assertEqual(self.remote_user.also_known_as.first(), self.local_user)
self.assertEqual(self.local_user.moved_to, "https://example.com/user/mouse")

View file

@ -8,7 +8,7 @@ from django.test.client import RequestFactory
import responses
from bookwyrm import models, views
from bookwyrm.settings import USER_AGENT, DOMAIN
from bookwyrm.settings import USER_AGENT, BASE_URL
@patch("bookwyrm.activitystreams.add_status_task.delay")
@ -288,13 +288,13 @@ class ViewsHelpers(TestCase): # pylint: disable=too-many-public-methods
def test_redirect_to_referer_valid_domain(self, *_):
"""redirect to within the app"""
request = self.factory.get("/path")
request.META = {"HTTP_REFERER": f"https://{DOMAIN}/and/a/path"}
request.META = {"HTTP_REFERER": f"{BASE_URL}/and/a/path"}
result = views.helpers.redirect_to_referer(request)
self.assertEqual(result.url, f"https://{DOMAIN}/and/a/path")
self.assertEqual(result.url, f"{BASE_URL}/and/a/path")
def test_redirect_to_referer_with_get_args(self, *_):
"""if the path has get params (like sort) they are preserved"""
request = self.factory.get("/path")
request.META = {"HTTP_REFERER": f"https://{DOMAIN}/and/a/path?sort=hello"}
request.META = {"HTTP_REFERER": f"{BASE_URL}/and/a/path?sort=hello"}
result = views.helpers.redirect_to_referer(request)
self.assertEqual(result.url, f"https://{DOMAIN}/and/a/path?sort=hello")
self.assertEqual(result.url, f"{BASE_URL}/and/a/path?sort=hello")

View file

@ -8,7 +8,7 @@ from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.tests.validate_html import validate_html
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
class IsbnViews(TestCase):
@ -55,7 +55,7 @@ class IsbnViews(TestCase):
data = json.loads(response.content)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["title"], "Test Book")
self.assertEqual(data[0]["key"], f"https://{DOMAIN}/book/{self.book.id}")
self.assertEqual(data[0]["key"], f"{BASE_URL}/book/{self.book.id}")
def test_isbn_html_response(self):
"""searches local data only and returns book data in json format"""

View file

@ -132,3 +132,27 @@ class RssFeedView(TestCase):
self.assertEqual(result.status_code, 200)
self.assertIn(b"a sickening sense", result.content)
def test_rss_shelf(self, *_):
"""load the rss feed of a shelf"""
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"
), patch("bookwyrm.activitystreams.add_book_statuses_task.delay"):
# make the shelf
shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
# put the shelf on the book
models.ShelfBook.objects.create(
book=self.book,
shelf=shelf,
user=self.local_user,
)
view = rss_feed.RssShelfFeed()
request = self.factory.get("/user/books/test-shelf/rss")
request.user = self.local_user
result = view(
request, username=self.local_user.username, shelf_identifier="test-shelf"
)
self.assertEqual(result.status_code, 200)
self.assertIn(b"Example Edition", result.content)

View file

@ -10,7 +10,7 @@ from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.book_search import SearchResult
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import BASE_URL
from bookwyrm.tests.validate_html import validate_html
@ -57,7 +57,7 @@ class Views(TestCase):
data = json.loads(response.content)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["title"], "Test Book")
self.assertEqual(data[0]["key"], f"https://{DOMAIN}/book/{self.book.id}")
self.assertEqual(data[0]["key"], f"{BASE_URL}/book/{self.book.id}")
def test_search_no_query(self):
"""just the search page"""

View file

@ -2,7 +2,7 @@
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import path, re_path
from django.urls import path, re_path, include
from django.views.generic.base import TemplateView
from bookwyrm import settings, views
@ -577,11 +577,21 @@ urlpatterns = [
views.Shelf.as_view(),
name="shelf",
),
re_path(
rf"^{USER_PATH}/(shelf|books)/(?P<shelf_identifier>[\w-]+)/rss/?$",
views.rss_feed.RssShelfFeed(),
name="shelf-rss",
),
re_path(
rf"^{LOCAL_USER_PATH}/(books|shelf)/(?P<shelf_identifier>[\w-]+)(.json)?/?$",
views.Shelf.as_view(),
name="shelf",
),
re_path(
rf"^{LOCAL_USER_PATH}/(books|shelf)/(?P<shelf_identifier>[\w-]+)/rss/?$",
views.rss_feed.RssShelfFeed(),
name="shelf-rss",
),
re_path(r"^create-shelf/?$", views.create_shelf, name="shelf-create"),
re_path(r"^delete-shelf/(?P<shelf_id>\d+)/?$", views.delete_shelf),
re_path(r"^shelve/?$", views.shelve),
@ -829,6 +839,7 @@ urlpatterns = [
r"^summary_revoke_key/?$", views.summary_revoke_key, name="summary-revoke-key"
),
path("guided-tour/<tour>", views.toggle_guided_tour),
re_path(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Serves /static when DEBUG is true.

View file

@ -1,21 +1,15 @@
"""Validations"""
from typing import Optional
from bookwyrm.settings import DOMAIN, USE_HTTPS
from bookwyrm.settings import BASE_URL
def validate_url_domain(url: str) -> Optional[str]:
def validate_url_domain(url: Optional[str]) -> Optional[str]:
"""Basic check that the URL starts with the instance domain name"""
if not url:
if url is None:
return None
if url == "/":
return url
if not url.startswith(BASE_URL):
return None
protocol = "https://" if USE_HTTPS else "http://"
origin = f"{protocol}{DOMAIN}"
if url.startswith(origin):
return url
return None
return url

View file

@ -61,7 +61,7 @@ def is_bookwyrm_request(request):
return True
def handle_remote_webfinger(query, unknown_only=False):
def handle_remote_webfinger(query, unknown_only=False, refresh=False):
"""webfingerin' other servers"""
user = None
@ -76,6 +76,11 @@ def handle_remote_webfinger(query, unknown_only=False):
return None
try:
if refresh:
# Always fetch the remote info - don't even bother checking the DB
raise models.User.DoesNotExist("remote_only is set to True")
user = models.User.objects.get(username__iexact=query)
if unknown_only:
@ -93,7 +98,7 @@ def handle_remote_webfinger(query, unknown_only=False):
if link.get("rel") == "self":
try:
user = activitypub.resolve_remote_id(
link["href"], model=models.User
link["href"], model=models.User, refresh=refresh
)
except (KeyError, activitypub.ActivitySerializerError):
return None

View file

@ -32,7 +32,7 @@ class MoveUser(View):
if form.is_valid() and user.check_password(form.cleaned_data["password"]):
username = form.cleaned_data["target"]
target = handle_remote_webfinger(username)
target = handle_remote_webfinger(username, refresh=True)
try:
models.MoveUser.objects.create(
@ -81,6 +81,7 @@ class AliasUser(View):
return TemplateResponse(request, "preferences/alias_user.html", data)
user.also_known_as.add(remote_user.id)
user.save(broadcast=True) # broadcast the alias
return redirect("prefs-alias")

View file

@ -3,6 +3,7 @@
from django.contrib.syndication.views import Feed
from django.template.loader import get_template
from django.utils.translation import gettext_lazy as _
from django.shortcuts import get_object_or_404
from ..models import Review, Quotation, Comment
from .helpers import get_user_from_username
@ -177,3 +178,61 @@ class RssCommentsOnlyFeed(Feed):
def item_pubdate(self, item):
"""publication date of the item"""
return item.published_date
class RssShelfFeed(Feed):
"""serialize a shelf activity in rss"""
description_template = "rss/edition.html"
def item_title(self, item):
"""render the item title"""
authors = item.authors
if item.author_text:
authors.display_name = f"{item.author_text}:"
else:
authors.description = ""
template = get_template("rss/title.html")
return template.render({"user": authors, "item_title": item.title}).strip()
def get_object(
self, request, shelf_identifier, username
): # pylint: disable=arguments-differ
"""the shelf that gets serialized"""
user = get_user_from_username(request.user, username)
# always get privacy, don't support rss over anything private
# get the SHELF of the object
shelf = get_object_or_404(
user.shelf_set,
identifier=shelf_identifier,
privacy__in=["public", "unlisted"],
)
shelf.raise_visible_to_user(request.user)
return shelf
def link(self, obj):
"""link to the shelf"""
return obj.local_path
def title(self, obj):
"""title of the rss feed entry"""
return _(f"{obj.user.display_name}s {obj.name} shelf")
def items(self, obj):
"""the user's activity feed"""
return obj.books.order_by("-shelfbook__shelved_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
def description(self, obj):
"""description of the shelf including the shelf name and user."""
# if there's a description, lets add it. Not everyone puts a description in.
if desc := obj.description:
return _(f"{obj.user.display_name}s {obj.name} shelf: {desc}")
return _(f"Books added to {obj.user.name}s {obj.name} shelf")

View file

@ -9,7 +9,7 @@ from django.utils import timezone
from django.views.decorators.http import require_GET
from bookwyrm import models
from bookwyrm.settings import DOMAIN, VERSION, LANGUAGE_CODE
from bookwyrm.settings import BASE_URL, DOMAIN, VERSION, LANGUAGE_CODE
@require_GET
@ -34,7 +34,7 @@ def webfinger(request):
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": f"https://{DOMAIN}/ostatus_subscribe?acct={{uri}}",
"template": f"{BASE_URL}/ostatus_subscribe?acct={{uri}}",
},
],
}

View file

@ -11,19 +11,20 @@ env =
DEBUG = false
USE_HTTPS = true
DOMAIN = your.domain.here
PORT = 4242
ALLOWED_HOSTS = your.domain.here
BOOKWYRM_DATABASE_BACKEND = postgres
MEDIA_ROOT = images/
CELERY_BROKER = ""
CELERY_BROKER =
REDIS_BROKER_PORT = 6379
REDIS_BROKER_PASSWORD = beep
REDIS_ACTIVITY_PORT = 6379
REDIS_ACTIVITY_PASSWORD = beep
USE_DUMMY_CACHE = true
FLOWER_PORT = 8888
EMAIL_HOST = "smtp.mailgun.org"
EMAIL_HOST = smtp.mailgun.org
EMAIL_PORT = 587
EMAIL_HOST_USER = ""
EMAIL_HOST_PASSWORD = ""
EMAIL_HOST_USER =
EMAIL_HOST_PASSWORD =
EMAIL_USE_TLS = true
ENABLE_PREVIEW_IMAGES = false

View file

@ -1,4 +1,4 @@
aiohttp==3.9.2
aiohttp==3.9.4
bleach==5.0.1
boto3==1.26.57
bw-file-resubmit==0.6.0rc2
@ -10,6 +10,7 @@ django-compressor==4.4
django-csp==3.7
django-imagekit==4.1.0
django-model-utils==4.3.1
django-oauth-toolkit==2.3.0
django-pgtrigger==4.11.0
django-redis==5.2.0
django-sass-processor==1.2.2