diff --git a/.env.example b/.env.example
index bd4f6698e..7769a67b1 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,8 @@ USE_HTTPS=true
DOMAIN=your.domain.here
EMAIL=your@email.here
+# Instance defualt language (see options at bookwyrm/settings.py "LANGUAGES"
+LANGUAGE_CODE="en-us"
# Used for deciding which editions to prefer
DEFAULT_LANGUAGE="English"
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index b32b04133..36898bc7e 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -38,7 +38,7 @@ class Create(Verb):
class Delete(Verb):
"""Create activity"""
- to: List[str]
+ to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
type: str = "Delete"
@@ -137,8 +137,8 @@ class Accept(Verb):
type: str = "Accept"
def action(self):
- """find and remove the activity object"""
- obj = self.object.to_model(save=False, allow_create=False)
+ """accept a request"""
+ obj = self.object.to_model(save=False, allow_create=True)
obj.accept()
@@ -150,7 +150,7 @@ class Reject(Verb):
type: str = "Reject"
def action(self):
- """find and remove the activity object"""
+ """reject a follow request"""
obj = self.object.to_model(save=False, allow_create=False)
obj.reject()
diff --git a/bookwyrm/apps.py b/bookwyrm/apps.py
index 8c9332cd2..af3b064e9 100644
--- a/bookwyrm/apps.py
+++ b/bookwyrm/apps.py
@@ -1,8 +1,34 @@
+"""Do further startup configuration and initialization"""
+import os
+import urllib
+import logging
+
from django.apps import AppConfig
+
from bookwyrm import settings
+logger = logging.getLogger(__name__)
+
+
+def download_file(url, destination):
+ """Downloads a file to the given path"""
+ try:
+ # Ensure our destination directory exists
+ os.makedirs(os.path.dirname(destination))
+ with urllib.request.urlopen(url) as stream:
+ with open(destination, "b+w") as outfile:
+ outfile.write(stream.read())
+ except (urllib.error.HTTPError, urllib.error.URLError):
+ logger.error("Failed to download file %s", url)
+ except OSError:
+ logger.error("Couldn't open font file %s for writing", destination)
+ except: # pylint: disable=bare-except
+ logger.exception("Unknown error in file download")
+
class BookwyrmConfig(AppConfig):
+ """Handles additional configuration"""
+
name = "bookwyrm"
verbose_name = "BookWyrm"
@@ -11,3 +37,15 @@ class BookwyrmConfig(AppConfig):
from bookwyrm.telemetry import open_telemetry
open_telemetry.instrumentDjango()
+
+ if settings.ENABLE_PREVIEW_IMAGES and settings.FONTS:
+ # Download any fonts that we don't have yet
+ logger.debug("Downloading fonts..")
+ for name, config in settings.FONTS.items():
+ font_path = os.path.join(
+ settings.FONT_DIR, config["directory"], config["filename"]
+ )
+
+ if "url" in config and not os.path.exists(font_path):
+ logger.info("Just a sec, downloading %s", name)
+ download_file(config["url"], font_path)
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index 5ed57df1f..d8b9c6300 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -1,7 +1,11 @@
""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
+import imghdr
+import ipaddress
import logging
+from urllib.parse import urlparse
+from django.core.files.base import ContentFile
from django.db import transaction
import requests
from requests.exceptions import RequestException
@@ -248,6 +252,8 @@ def dict_from_mappings(data, mappings):
def get_data(url, params=None, timeout=10):
"""wrapper for request.get"""
# check if the url is blocked
+ raise_not_valid_url(url)
+
if models.FederatedServer.is_blocked(url):
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
@@ -280,6 +286,7 @@ def get_data(url, params=None, timeout=10):
def get_image(url, timeout=10):
"""wrapper for requesting an image"""
+ raise_not_valid_url(url)
try:
resp = requests.get(
url,
@@ -290,10 +297,32 @@ def get_image(url, timeout=10):
)
except RequestException as err:
logger.exception(err)
- return None
+ return None, None
+
if not resp.ok:
- return None
- return resp
+ return None, None
+
+ image_content = ContentFile(resp.content)
+ extension = imghdr.what(None, image_content.read())
+ if not extension:
+ logger.exception("File requested was not an image: %s", url)
+ return None, None
+
+ return image_content, extension
+
+
+def raise_not_valid_url(url):
+ """do some basic reality checks on the url"""
+ parsed = urlparse(url)
+ if not parsed.scheme in ["http", "https"]:
+ raise ConnectorException("Invalid scheme: ", url)
+
+ try:
+ ipaddress.ip_address(parsed.netloc)
+ raise ConnectorException("Provided url is an IP address: ", url)
+ except ValueError:
+ # it's not an IP address, which is good
+ pass
class Mapping:
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index efef12638..80aca071b 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -48,7 +48,9 @@ def moderation_report_email(report):
data["reportee"] = report.user.localname or report.user.username
data["report_link"] = report.remote_id
- for admin in models.User.objects.filter(groups__name__in=["admin", "moderator"]):
+ for admin in models.User.objects.filter(
+ groups__name__in=["admin", "moderator"]
+ ).distinct():
data["user"] = admin.display_name
send_email.delay(admin.email, *format_email("moderation_report", data))
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 5ab908955..564ea91b2 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -1,6 +1,7 @@
""" using django model forms """
import datetime
from collections import defaultdict
+from urllib.parse import urlparse
from django import forms
from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
@@ -227,6 +228,34 @@ class FileLinkForm(CustomForm):
model = models.FileLink
fields = ["url", "filetype", "availability", "book", "added_by"]
+ def clean(self):
+ """make sure the domain isn't blocked or pending"""
+ cleaned_data = super().clean()
+ url = cleaned_data.get("url")
+ filetype = cleaned_data.get("filetype")
+ book = cleaned_data.get("book")
+ domain = urlparse(url).netloc
+ if models.LinkDomain.objects.filter(domain=domain).exists():
+ status = models.LinkDomain.objects.get(domain=domain).status
+ if status == "blocked":
+ # pylint: disable=line-too-long
+ self.add_error(
+ "url",
+ _(
+ "This domain is blocked. Please contact your administrator if you think this is an error."
+ ),
+ )
+ elif models.FileLink.objects.filter(
+ url=url, book=book, filetype=filetype
+ ).exists():
+ # pylint: disable=line-too-long
+ self.add_error(
+ "url",
+ _(
+ "This link with file type has already been added for this book. If it is not visible, the domain is still pending."
+ ),
+ )
+
class EditionForm(CustomForm):
class Meta:
@@ -444,6 +473,12 @@ class ListForm(CustomForm):
fields = ["user", "name", "description", "curation", "privacy", "group"]
+class ListItemForm(CustomForm):
+ class Meta:
+ model = models.ListItem
+ fields = ["user", "book", "book_list", "notes"]
+
+
class GroupForm(CustomForm):
class Meta:
model = models.Group
@@ -500,7 +535,7 @@ class ReadThroughForm(CustomForm):
cleaned_data = super().clean()
start_date = cleaned_data.get("start_date")
finish_date = cleaned_data.get("finish_date")
- if start_date > finish_date:
+ if start_date and finish_date and start_date > finish_date:
self.add_error(
"finish_date", _("Reading finish date cannot be before start date.")
)
diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py
index 37dd66af4..09d864626 100644
--- a/bookwyrm/management/commands/initdb.py
+++ b/bookwyrm/management/commands/initdb.py
@@ -19,9 +19,7 @@ def init_permissions():
{
"codename": "edit_instance_settings",
"name": "change the instance info",
- "groups": [
- "admin",
- ],
+ "groups": ["admin"],
},
{
"codename": "set_user_group",
@@ -55,7 +53,7 @@ def init_permissions():
},
]
- content_type = models.ContentType.objects.get_for_model(User)
+ content_type = ContentType.objects.get_for_model(models.User)
for permission in permissions:
permission_obj = Permission.objects.create(
codename=permission["codename"],
@@ -66,15 +64,12 @@ def init_permissions():
for group_name in permission["groups"]:
Group.objects.get(name=group_name).permissions.add(permission_obj)
- # while the groups and permissions shouldn't be changed because the code
- # depends on them, what permissions go with what groups should be editable
-
def init_connectors():
"""access book data sources"""
models.Connector.objects.create(
identifier="bookwyrm.social",
- name="BookWyrm dot Social",
+ name="Bookwyrm.social",
connector_file="bookwyrm_connector",
base_url="https://bookwyrm.social",
books_url="https://bookwyrm.social/book",
@@ -84,6 +79,7 @@ def init_connectors():
priority=2,
)
+ # pylint: disable=line-too-long
models.Connector.objects.create(
identifier="inventaire.io",
name="Inventaire",
@@ -127,7 +123,7 @@ def init_settings():
)
-def init_link_domains(*_):
+def init_link_domains():
"""safe book links"""
domains = [
("standardebooks.org", "Standard EBooks"),
@@ -144,10 +140,15 @@ def init_link_domains(*_):
)
+# pylint: disable=no-self-use
+# pylint: disable=unused-argument
class Command(BaseCommand):
+ """command-line options"""
+
help = "Initializes the database with starter data"
def add_arguments(self, parser):
+ """specify which function to run"""
parser.add_argument(
"--limit",
default=None,
@@ -155,6 +156,7 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
+ """execute init"""
limit = options.get("limit")
tables = [
"group",
@@ -164,7 +166,7 @@ class Command(BaseCommand):
"settings",
"linkdomain",
]
- if limit not in tables:
+ if limit and limit not in tables:
raise Exception("Invalid table limit:", limit)
if not limit or limit == "group":
diff --git a/bookwyrm/migrations/0130_alter_listitem_notes.py b/bookwyrm/migrations/0130_alter_listitem_notes.py
new file mode 100644
index 000000000..a12efd40d
--- /dev/null
+++ b/bookwyrm/migrations/0130_alter_listitem_notes.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.10 on 2022-01-24 20:01
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0129_auto_20220117_1716"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="listitem",
+ name="notes",
+ field=bookwyrm.models.fields.TextField(
+ blank=True, max_length=300, null=True
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0130_alter_user_preferred_language.py b/bookwyrm/migrations/0130_alter_user_preferred_language.py
new file mode 100644
index 000000000..cd5a07eab
--- /dev/null
+++ b/bookwyrm/migrations/0130_alter_user_preferred_language.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.2.10 on 2022-01-24 17:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0129_auto_20220117_1716"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "Deutsch (German)"),
+ ("es-es", "Español (Spanish)"),
+ ("gl-es", "Galego (Galician)"),
+ ("it-it", "Italiano (Italian)"),
+ ("fr-fr", "Français (French)"),
+ ("lt-lt", "Lietuvių (Lithuanian)"),
+ ("no-no", "Norsk (Norwegian)"),
+ ("pt-br", "Português do Brasil (Brazilian Portuguese)"),
+ ("pt-pt", "Português Europeu (European Portuguese)"),
+ ("sv-se", "Swedish (Svenska)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0131_merge_20220125_1644.py b/bookwyrm/migrations/0131_merge_20220125_1644.py
new file mode 100644
index 000000000..954ddacc4
--- /dev/null
+++ b/bookwyrm/migrations/0131_merge_20220125_1644.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.10 on 2022-01-25 16:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0130_alter_listitem_notes"),
+ ("bookwyrm", "0130_alter_user_preferred_language"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0132_alter_user_preferred_language.py b/bookwyrm/migrations/0132_alter_user_preferred_language.py
new file mode 100644
index 000000000..a2f0aa6a7
--- /dev/null
+++ b/bookwyrm/migrations/0132_alter_user_preferred_language.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.2.10 on 2022-02-02 20:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0131_merge_20220125_1644"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "Deutsch (German)"),
+ ("es-es", "Español (Spanish)"),
+ ("gl-es", "Galego (Galician)"),
+ ("it-it", "Italiano (Italian)"),
+ ("fr-fr", "Français (French)"),
+ ("lt-lt", "Lietuvių (Lithuanian)"),
+ ("no-no", "Norsk (Norwegian)"),
+ ("pt-br", "Português do Brasil (Brazilian Portuguese)"),
+ ("pt-pt", "Português Europeu (European Portuguese)"),
+ ("sv-se", "Svenska (Swedish)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0133_alter_listitem_notes.py b/bookwyrm/migrations/0133_alter_listitem_notes.py
new file mode 100644
index 000000000..26ed10f82
--- /dev/null
+++ b/bookwyrm/migrations/0133_alter_listitem_notes.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.11 on 2022-02-04 20:06
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0132_alter_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="listitem",
+ name="notes",
+ field=bookwyrm.models.fields.HtmlField(
+ blank=True, max_length=300, null=True
+ ),
+ ),
+ ]
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index 8d1b70ae2..ffc03d3e6 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -342,6 +342,11 @@ class Edition(Book):
# set rank
self.edition_rank = self.get_rank()
+ # clear author cache
+ if self.id:
+ for author_id in self.authors.values_list("id", flat=True):
+ cache.delete(f"author-books-{author_id}")
+
return super().save(*args, **kwargs)
@classmethod
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index e61f912e5..b506c11ca 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -1,6 +1,5 @@
""" activitypub-aware django model fields """
from dataclasses import MISSING
-import imghdr
import re
from uuid import uuid4
from urllib.parse import urljoin
@@ -9,7 +8,6 @@ import dateutil.parser
from dateutil.parser import ParserError
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
-from django.core.files.base import ContentFile
from django.db import models
from django.forms import ClearableFileInput, ImageField as DjangoImageField
from django.utils import timezone
@@ -443,12 +441,10 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
except ValidationError:
return None
- response = get_image(url)
- if not response:
+ image_content, extension = get_image(url)
+ if not image_content:
return None
- image_content = ContentFile(response.content)
- extension = imghdr.what(None, image_content.read()) or ""
image_name = f"{uuid4()}.{extension}"
return [image_name, image_content]
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index d159bc4a8..ea524cc54 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -2,6 +2,7 @@
import uuid
from django.apps import apps
+from django.core.exceptions import PermissionDenied
from django.db import models
from django.db.models import Q
from django.utils import timezone
@@ -74,6 +75,22 @@ class List(OrderedCollectionMixin, BookWyrmModel):
return
super().raise_not_editable(viewer)
+ def raise_not_submittable(self, viewer):
+ """can the user submit a book to the list?"""
+ # if you can't view the list you can't submit to it
+ self.raise_visible_to_user(viewer)
+
+ # all good if you're the owner or the list is open
+ if self.user == viewer or self.curation in ["open", "curated"]:
+ return
+ if self.curation == "group":
+ is_group_member = GroupMember.objects.filter(
+ group=self.group, user=viewer
+ ).exists()
+ if is_group_member:
+ return
+ raise PermissionDenied()
+
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override filter for "followers" privacy level to allow non-following
@@ -125,7 +142,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
- notes = fields.TextField(blank=True, null=True)
+ notes = fields.HtmlField(blank=True, null=True, max_length=300)
approved = models.BooleanField(default=True)
order = fields.IntegerField()
endorsement = models.ManyToManyField("User", related_name="endorsers")
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index a9f5b3b1e..bd2a1ef0e 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -1,5 +1,6 @@
""" flagged for moderation """
from django.db import models
+from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
@@ -15,6 +16,9 @@ class Report(BookWyrmModel):
links = models.ManyToManyField("Link", blank=True)
resolved = models.BooleanField(default=False)
+ def get_remote_id(self):
+ return f"https://{DOMAIN}/settings/reports/{self.id}"
+
class Meta:
"""set order by default"""
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index c578f0827..320d495d2 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -1,5 +1,6 @@
""" puttin' books on shelves """
import re
+from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils import timezone
@@ -94,8 +95,15 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
def save(self, *args, **kwargs):
if not self.user:
self.user = self.shelf.user
+ if self.id and self.user.local:
+ cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
super().save(*args, **kwargs)
+ def delete(self, *args, **kwargs):
+ if self.id and self.user.local:
+ cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
+ super().delete(*args, **kwargs)
+
class Meta:
"""an opinionated constraint!
you can't put a book on shelf twice"""
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index 5d91553e3..b2119e238 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -90,6 +90,14 @@ class SiteSettings(models.Model):
return get_absolute_url(uploaded)
return urljoin(STATIC_FULL_URL, default_path)
+ def save(self, *args, **kwargs):
+ """if require_confirm_email is disabled, make sure no users are pending"""
+ if not self.require_confirm_email:
+ User.objects.filter(is_active=False, deactivation_reason="pending").update(
+ is_active=True, deactivation_reason=None
+ )
+ super().save(*args, **kwargs)
+
class SiteInvite(models.Model):
"""gives someone access to create an account on the instance"""
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index ee138d979..29b3ba9cc 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -3,6 +3,7 @@ from dataclasses import MISSING
import re
from django.apps import apps
+from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -373,6 +374,12 @@ class Review(BookStatus):
activity_serializer = activitypub.Review
pure_type = "Article"
+ def save(self, *args, **kwargs):
+ """clear rating caches"""
+ if self.book.parent_work:
+ cache.delete(f"book-rating-{self.book.parent_work.id}-*")
+ super().save(*args, **kwargs)
+
class ReviewRating(Review):
"""a subtype of review that only contains a rating"""
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
index a97ae2d5c..891c8b6da 100644
--- a/bookwyrm/preview_images.py
+++ b/bookwyrm/preview_images.py
@@ -4,6 +4,7 @@ import os
import textwrap
from io import BytesIO
from uuid import uuid4
+import logging
import colorsys
from colorthief import ColorThief
@@ -17,34 +18,49 @@ from django.db.models import Avg
from bookwyrm import models, settings
from bookwyrm.tasks import app
+logger = logging.getLogger(__name__)
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
BG_COLOR = settings.PREVIEW_BG_COLOR
TEXT_COLOR = settings.PREVIEW_TEXT_COLOR
DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR
+DEFAULT_FONT = settings.PREVIEW_DEFAULT_FONT
TRANSPARENT_COLOR = (0, 0, 0, 0)
margin = math.floor(IMG_HEIGHT / 10)
gutter = math.floor(margin / 2)
inner_img_height = math.floor(IMG_HEIGHT * 0.8)
inner_img_width = math.floor(inner_img_height * 0.7)
-font_dir = os.path.join(settings.STATIC_ROOT, "fonts/public_sans")
-def get_font(font_name, size=28):
- """Loads custom font"""
- if font_name == "light":
- font_path = os.path.join(font_dir, "PublicSans-Light.ttf")
- if font_name == "regular":
- font_path = os.path.join(font_dir, "PublicSans-Regular.ttf")
- elif font_name == "bold":
- font_path = os.path.join(font_dir, "PublicSans-Bold.ttf")
+def get_imagefont(name, size):
+ """Loads an ImageFont based on config"""
+ try:
+ config = settings.FONTS[name]
+ path = os.path.join(settings.FONT_DIR, config["directory"], config["filename"])
+ return ImageFont.truetype(path, size)
+ except KeyError:
+ logger.error("Font %s not found in config", name)
+ except OSError:
+ logger.error("Could not load font %s from file", name)
+
+ return ImageFont.load_default()
+
+
+def get_font(weight, size=28):
+ """Gets a custom font with the given weight and size"""
+ font = get_imagefont(DEFAULT_FONT, size)
try:
- font = ImageFont.truetype(font_path, size)
- except OSError:
- font = ImageFont.load_default()
+ if weight == "light":
+ font.set_variation_by_name("Light")
+ if weight == "bold":
+ font.set_variation_by_name("Bold")
+ if weight == "regular":
+ font.set_variation_by_name("Regular")
+ except AttributeError:
+ pass
return font
diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py
index 8b0e3c4cb..4edd2818e 100644
--- a/bookwyrm/sanitize_html.py
+++ b/bookwyrm/sanitize_html.py
@@ -22,6 +22,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
"ol",
"li",
]
+ self.allowed_attrs = ["href", "rel", "src", "alt"]
self.tag_stack = []
self.output = []
# if the html appears invalid, we just won't allow any at all
@@ -30,7 +31,14 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
def handle_starttag(self, tag, attrs):
"""check if the tag is valid"""
if self.allow_html and tag in self.allowed_tags:
- self.output.append(("tag", self.get_starttag_text()))
+ allowed_attrs = " ".join(
+ f'{a}="{v}"' for a, v in attrs if a in self.allowed_attrs
+ )
+ reconstructed = f"<{tag}"
+ if allowed_attrs:
+ reconstructed += " " + allowed_attrs
+ reconstructed += ">"
+ self.output.append(("tag", reconstructed))
self.tag_stack.append(tag)
else:
self.output.append(("data", ""))
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index fe2c0ac76..8b715cc50 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -9,12 +9,12 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
-VERSION = "0.2.0"
+VERSION = "0.3.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
-JS_CACHE = "76c5ff1f"
+JS_CACHE = "7b5303af"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@@ -35,6 +35,9 @@ LOCALE_PATHS = [
]
LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language")
+STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
+MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
+
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Preview image
@@ -44,6 +47,17 @@ PREVIEW_TEXT_COLOR = env.str("PREVIEW_TEXT_COLOR", "#363636")
PREVIEW_IMG_WIDTH = env.int("PREVIEW_IMG_WIDTH", 1200)
PREVIEW_IMG_HEIGHT = env.int("PREVIEW_IMG_HEIGHT", 630)
PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549")
+PREVIEW_DEFAULT_FONT = env.str("PREVIEW_DEFAULT_FONT", "Source Han Sans")
+
+FONTS = {
+ # pylint: disable=line-too-long
+ "Source Han Sans": {
+ "directory": "source_han_sans",
+ "filename": "SourceHanSans-VF.ttf.ttc",
+ "url": "https://github.com/adobe-fonts/source-han-sans/raw/release/Variable/OTC/SourceHanSans-VF.ttf.ttc",
+ }
+}
+FONT_DIR = os.path.join(STATIC_ROOT, "fonts")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
@@ -106,6 +120,61 @@ TEMPLATES = [
},
]
+LOG_LEVEL = env("LOG_LEVEL", "INFO").upper()
+# Override aspects of the default handler to our taste
+# See https://docs.djangoproject.com/en/3.2/topics/logging/#default-logging-configuration
+# for a reference to the defaults we're overriding
+#
+# It seems that in order to override anything you have to include its
+# entire dependency tree (handlers and filters) which makes this a
+# bit verbose
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "filters": {
+ # These are copied from the default configuration, required for
+ # implementing mail_admins below
+ "require_debug_false": {
+ "()": "django.utils.log.RequireDebugFalse",
+ },
+ "require_debug_true": {
+ "()": "django.utils.log.RequireDebugTrue",
+ },
+ },
+ "handlers": {
+ # Overrides the default handler to make it log to console
+ # regardless of the DEBUG setting (default is to not log to
+ # console if DEBUG=False)
+ "console": {
+ "level": LOG_LEVEL,
+ "class": "logging.StreamHandler",
+ },
+ # This is copied as-is from the default logger, and is
+ # required for the django section below
+ "mail_admins": {
+ "level": "ERROR",
+ "filters": ["require_debug_false"],
+ "class": "django.utils.log.AdminEmailHandler",
+ },
+ },
+ "loggers": {
+ # Install our new console handler for Django's logger, and
+ # override the log level while we're at it
+ "django": {
+ "handlers": ["console", "mail_admins"],
+ "level": LOG_LEVEL,
+ },
+ "django.utils.autoreload": {
+ "level": "INFO",
+ },
+ # Add a bookwyrm-specific logger
+ "bookwyrm": {
+ "handlers": ["console"],
+ "level": LOG_LEVEL,
+ },
+ },
+}
+
WSGI_APPLICATION = "bookwyrm.wsgi.application"
@@ -196,7 +265,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
-LANGUAGE_CODE = "en-us"
+LANGUAGE_CODE = env("LANGUAGE_CODE", "en-us")
LANGUAGES = [
("en-us", _("English")),
("de-de", _("Deutsch (German)")),
@@ -208,6 +277,7 @@ LANGUAGES = [
("no-no", _("Norsk (Norwegian)")),
("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
("pt-pt", _("Português Europeu (European Portuguese)")),
+ ("sv-se", _("Svenska (Swedish)")),
("zh-hans", _("简体中文 (Simplified Chinese)")),
("zh-hant", _("繁體中文 (Traditional Chinese)")),
]
@@ -263,16 +333,11 @@ if USE_S3:
MEDIA_FULL_URL = MEDIA_URL
STATIC_FULL_URL = STATIC_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
- # I don't know if it's used, but the site crashes without it
- STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
- MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
else:
STATIC_URL = "/static/"
- STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
MEDIA_URL = "/images/"
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
- MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
diff --git a/bookwyrm/static/fonts/source_han_sans/LICENSE.txt b/bookwyrm/static/fonts/source_han_sans/LICENSE.txt
new file mode 100644
index 000000000..ddf7b7e91
--- /dev/null
+++ b/bookwyrm/static/fonts/source_han_sans/LICENSE.txt
@@ -0,0 +1,96 @@
+Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font
+Name 'Source'. Source is a trademark of Adobe in the United States
+and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/bookwyrm/static/fonts/source_han_sans/README.txt b/bookwyrm/static/fonts/source_han_sans/README.txt
new file mode 100644
index 000000000..53cfa9b8f
--- /dev/null
+++ b/bookwyrm/static/fonts/source_han_sans/README.txt
@@ -0,0 +1,9 @@
+The font file itself is not included in the Git repository to avoid putting
+large files in the repo history. The Docker image should download the correct
+font into this folder automatically.
+
+In case something goes wrong, the font used is the Variable OTC TTF, available
+as of this writing from the Adobe Fonts GitHub repository:
+https://github.com/adobe-fonts/source-han-sans/tree/release#user-content-variable-otcs
+
+BookWyrm expects the file to be in this folder, named SourceHanSans-VF.ttf.ttc
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index 94163787d..cf3ce3032 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -122,39 +122,13 @@ let BookWyrm = new (class {
*/
updateCountElement(counter, data) {
let count = data.count;
- const count_by_type = data.count_by_type;
+
+ if (count === undefined) {
+ return;
+ }
+
const currentCount = counter.innerText;
const hasMentions = data.has_mentions;
- const allowedStatusTypesEl = document.getElementById("unread-notifications-wrapper");
-
- // If we're on the right counter element
- if (counter.closest("[data-poll-wrapper]").contains(allowedStatusTypesEl)) {
- const allowedStatusTypes = JSON.parse(allowedStatusTypesEl.textContent);
-
- // For keys in common between allowedStatusTypes and count_by_type
- // This concerns 'review', 'quotation', 'comment'
- count = allowedStatusTypes.reduce(function (prev, currentKey) {
- const currentValue = count_by_type[currentKey] | 0;
-
- return prev + currentValue;
- }, 0);
-
- // Add all the "other" in count_by_type if 'everything' is allowed
- if (allowedStatusTypes.includes("everything")) {
- // Clone count_by_type with 0 for reviews/quotations/comments
- const count_by_everything_else = Object.assign({}, count_by_type, {
- review: 0,
- quotation: 0,
- comment: 0,
- });
-
- count = Object.keys(count_by_everything_else).reduce(function (prev, currentKey) {
- const currentValue = count_by_everything_else[currentKey] | 0;
-
- return prev + currentValue;
- }, count);
- }
- }
if (count != currentCount) {
this.addRemoveClass(counter.closest("[data-poll-wrapper]"), "is-hidden", count < 1);
@@ -517,7 +491,7 @@ let BookWyrm = new (class {
duplicateInput(event) {
const trigger = event.currentTarget;
- const input_id = trigger.dataset["duplicate"];
+ const input_id = trigger.dataset.duplicate;
const orig = document.getElementById(input_id);
const parent = orig.parentNode;
const new_count = parent.querySelectorAll("input").length + 1;
diff --git a/bookwyrm/templates/about/about.html b/bookwyrm/templates/about/about.html
index d39d70486..6f16aa675 100644
--- a/bookwyrm/templates/about/about.html
+++ b/bookwyrm/templates/about/about.html
@@ -2,7 +2,7 @@
{% load humanize %}
{% load i18n %}
{% load utilities %}
-{% load bookwyrm_tags %}
+{% load landing_page_tags %}
{% load cache %}
{% block title %}
@@ -12,6 +12,7 @@
{% block about_content %}
{# seven day cache #}
{% cache 604800 about_page %}
+
{% get_book_superlatives as superlatives %}
@@ -26,8 +27,8 @@
{% url "conduct" as coc_path %} - {% blocktrans with site_name=site.name %} + {% blocktrans trimmed with site_name=site.name %} {{ site_name }}'s moderators and administrators keep the site up and running, enforce the code of conduct, and respond when users report spam and bad behavior. {% endblocktrans %}
diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html index 27beeb468..afbf31784 100644 --- a/bookwyrm/templates/author/author.html +++ b/bookwyrm/templates/author/author.html @@ -66,7 +66,7 @@- + {% trans "View on OpenLibrary" %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} @@ -136,7 +136,7 @@ {% endif %} {% if book.inventaire_id %}
- + {% trans "View on Inventaire" %} @@ -356,10 +356,11 @@