Merge branch 'main' into show-2fa-code

This commit is contained in:
Mouse Reeve 2022-11-19 10:09:27 -08:00 committed by GitHub
commit 1e5a6ec744
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 274 additions and 90 deletions

View file

@ -37,7 +37,7 @@ Keep track of what books you've read, and what books you'd like to read in the f
Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books. Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books.
### Privacy and moderation ### Privacy and moderation
Users and administrators can control who can see thier posts and what other instances to federate with. Users and administrators can control who can see their posts and what other instances to federate with.
## Tech Stack ## Tech Stack
Web backend Web backend

View file

@ -271,7 +271,7 @@ def resolve_remote_id(
try: try:
data = get_data(remote_id) data = get_data(remote_id)
except ConnectorException: except ConnectorException:
logger.exception("Could not connect to host for remote_id: %s", remote_id) logger.info("Could not connect to host for remote_id: %s", remote_id)
return None return None
# determine the model implicitly, if not provided # determine the model implicitly, if not provided

View file

@ -222,7 +222,7 @@ def dict_from_mappings(data, mappings):
return result return result
def get_data(url, params=None, timeout=10): def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
"""wrapper for request.get""" """wrapper for request.get"""
# check if the url is blocked # check if the url is blocked
raise_not_valid_url(url) raise_not_valid_url(url)

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2022-11-17 21:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0165_alter_inviterequest_answer"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="imports_enabled",
field=models.BooleanField(default=True),
),
]

View file

@ -86,6 +86,9 @@ class SiteSettings(SiteModel):
admin_email = models.EmailField(max_length=255, null=True, blank=True) admin_email = models.EmailField(max_length=255, null=True, blank=True)
footer_item = models.TextField(null=True, blank=True) footer_item = models.TextField(null=True, blank=True)
# controls
imports_enabled = models.BooleanField(default=True)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"]) field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
@classmethod @classmethod

View file

@ -244,9 +244,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def admins(cls): def admins(cls):
"""Get a queryset of the admins for this instance""" """Get a queryset of the admins for this instance"""
return cls.objects.filter( return cls.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"]) models.Q(groups__name__in=["moderator", "admin"])
| models.Q(is_superuser=True) | models.Q(is_superuser=True),
) is_active=True,
).distinct()
def update_active_date(self): def update_active_date(self):
"""this user is here! they are doing things!""" """this user is here! they are doing things!"""

View file

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env() env = Env()
env.read_env() env.read_env()
DOMAIN = env("DOMAIN") DOMAIN = env("DOMAIN")
VERSION = "0.4.6" VERSION = "0.5.1"
RELEASE_API = env( RELEASE_API = env(
"RELEASE_API", "RELEASE_API",

View file

@ -92,6 +92,10 @@ $family-secondary: $family-sans-serif;
color: $grey-light !important; color: $grey-light !important;
} }
#qrcode svg {
background-color: #a6a6a6;
}
@import "../bookwyrm.scss"; @import "../bookwyrm.scss";
@import "../vendor/icons.css"; @import "../vendor/icons.css";
@import "../vendor/shepherd.scss"; @import "../vendor/shepherd.scss";

View file

@ -11,7 +11,7 @@
{% block about_content %} {% block about_content %}
{# seven day cache #} {# seven day cache #}
{% cache 604800 about_page %} {% cache 604800 about_page_superlatives %}
{% get_book_superlatives as superlatives %} {% get_book_superlatives as superlatives %}
<section class=" pb-4"> <section class=" pb-4">
@ -97,6 +97,7 @@
</p> </p>
</section> </section>
{% endcache %}
<section class="block"> <section class="block">
<header class="content"> <header class="content">
@ -145,5 +146,4 @@
</div> </div>
</section> </section>
{% endcache %}
{% endblock %} {% endblock %}

View file

@ -1,14 +1,13 @@
{% load layout %} {% load layout %}
{% load i18n %} {% load i18n %}
{% load sass_tags %}
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{% get_lang %}"> <html lang="{% get_lang %}">
<head> <head>
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title> <title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}"> <link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
<base target="_blank"> <base target="_blank">

View file

@ -8,83 +8,94 @@
<div class="block"> <div class="block">
<h1 class="title">{% trans "Import Books" %}</h1> <h1 class="title">{% trans "Import Books" %}</h1>
{% if recent_avg_hours or recent_avg_minutes %} {% if site.imports_enabled %}
<div class="notification"> {% if recent_avg_hours or recent_avg_minutes %}
<p> <div class="notification">
{% if recent_avg_hours %} <p>
{% blocktrans trimmed with hours=recent_avg_hours|floatformat:0|intcomma %} {% if recent_avg_hours %}
On average, recent imports have taken {{ hours }} hours. {% blocktrans trimmed with hours=recent_avg_hours|floatformat:0|intcomma %}
{% endblocktrans %} On average, recent imports have taken {{ hours }} hours.
{% else %} {% endblocktrans %}
{% blocktrans trimmed with minutes=recent_avg_minutes|floatformat:0|intcomma %} {% else %}
On average, recent imports have taken {{ minutes }} minutes. {% blocktrans trimmed with minutes=recent_avg_minutes|floatformat:0|intcomma %}
{% endblocktrans %} On average, recent imports have taken {{ minutes }} minutes.
{% endblocktrans %}
{% endif %}
</p>
</div>
{% endif %} {% endif %}
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="source">
{% trans "Data source:" %}
</label>
<div class="select">
<select name="source" id="source" aria-describedby="desc_source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
{% trans "Goodreads (CSV)" %}
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
{% trans "Storygraph (CSV)" %}
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
{% trans "LibraryThing (TSV)" %}
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
{% trans "OpenLibrary (CSV)" %}
</option>
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
{% trans "Calibre (CSV)" %}
</option>
</select>
</div>
<p class="help" id="desc_source">
{% blocktrans trimmed %}
You can download your Goodreads data from the
<a href="https://www.goodreads.com/review/import" target="_blank" rel="nofollow noopener noreferrer">Import/Export page</a>
of your Goodreads account.
{% endblocktrans %}
</p>
</div>
<div class="field">
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
{{ import_form.csv_file }}
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label">
<input type="checkbox" name="include_reviews" checked> {% trans "Include reviews" %}
</label>
</div>
<div class="field">
<label class="label" for="privacy_import">
{% trans "Privacy setting for imported reviews:" %}
</label>
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
</div>
</div>
</div>
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
</form>
{% else %}
<div class="box notification has-text-centered is-warning m-6 content">
<p class="mt-5">
<span class="icon icon-warning is-size-2" aria-hidden="true"></span>
</p>
<p class="mb-5">
{% trans "Imports are temporarily disabled; thank you for your patience." %}
</p> </p>
</div> </div>
{% endif %} {% endif %}
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="source">
{% trans "Data source:" %}
</label>
<div class="select">
<select name="source" id="source" aria-describedby="desc_source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
{% trans "Goodreads (CSV)" %}
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
{% trans "Storygraph (CSV)" %}
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
{% trans "LibraryThing (TSV)" %}
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
{% trans "OpenLibrary (CSV)" %}
</option>
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
{% trans "Calibre (CSV)" %}
</option>
</select>
</div>
<p class="help" id="desc_source">
{% blocktrans trimmed %}
You can download your Goodreads data from the
<a href="https://www.goodreads.com/review/import" target="_blank" rel="nofollow noopener noreferrer">Import/Export page</a>
of your Goodreads account.
{% endblocktrans %}
</p>
</div>
<div class="field">
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
{{ import_form.csv_file }}
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label">
<input type="checkbox" name="include_reviews" checked> {% trans "Include reviews" %}
</label>
</div>
<div class="field">
<label class="label" for="privacy_import">
{% trans "Privacy setting for imported reviews:" %}
</label>
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
</div>
</div>
</div>
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
</form>
</div> </div>
<div class="content block"> <div class="content block">

View file

@ -5,7 +5,9 @@
{% load group_tags %} {% load group_tags %}
{% load markdown %} {% load markdown %}
{% block title %}{% blocktrans with list_name=list.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}}{% endblocktrans %}{% endblock title %} {% block title %}{% blocktrans trimmed with list_name=list.name owner=list.user.display_name %}
{{ list_name }}, a list by {{owner}}
{% endblocktrans %}{% endblock title %}
{% block content %} {% block content %}
<div class="mt-3"> <div class="mt-3">

View file

@ -45,7 +45,7 @@
<p>{% trans "Scan the QR code with your authentication app and then enter the code from your app below to confirm your app is set up." %}</p> <p>{% trans "Scan the QR code with your authentication app and then enter the code from your app below to confirm your app is set up." %}</p>
<div class="columns"> <div class="columns">
<section class="column is-narrow"> <section class="column is-narrow">
<figure class="m-4">{{ qrcode | safe }}</figure> <figure class="m-4" id="qrcode">{{ qrcode | safe }}</figure>
<details class="details-panel box"> <details class="details-panel box">
<summary> <summary>
<span role="heading" aria-level="3" class="title is-6"> <span role="heading" aria-level="3" class="title is-6">

View file

@ -11,6 +11,54 @@
{% block panel %} {% block panel %}
<div class="block">
{% if site.imports_enabled %}
<details class="details-panel box">
<summary>
<span role="heading" aria-level="2" class="title is-6">
{% trans "Disable starting new imports" %}
</span>
<span class="details-close icon icon-x" aria-hidden="true"></span>
</summary>
<form
name="disable-imports"
id="disable-imports"
method="POST"
action="{% url 'settings-imports-disable' %}"
>
<div class="notification">
{% trans "This is only intended to be used when things have gone very wrong with imports and you need to pause the feature while addressing issues." %}
{% trans "While imports are disabled, users will not be allowed to start new imports, but existing imports will not be effected." %}
</div>
{% csrf_token %}
<div class="control">
<button type="submit" class="button is-danger">
{% trans "Disable imports" %}
</button>
</div>
</form>
</details>
{% else %}
<form
name="enable-imports"
id="enable-imports"
method="POST"
action="{% url 'settings-imports-enable' %}"
class="box"
>
<div class="notification is-danger is-light">
{% trans "Users are currently unable to start new imports" %}
</div>
{% csrf_token %}
<div class="control">
<button type="submit" class="button is-success">
{% trans "Enable imports" %}
</button>
</div>
</form>
{% endif %}
</div>
<div class="block"> <div class="block">
<div class="tabs"> <div class="tabs">
<ul> <ul>

View file

@ -24,7 +24,7 @@
</div> </div>
<div class="column is-2"> <div class="column is-2">
<p> <p>
<a href ="{% url 'privacy' %}">{% trans "Code of Conduct" %}</a> <a href ="{% url 'conduct' %}">{% trans "Code of Conduct" %}</a>
</p> </p>
<p> <p>
<a href ="{% url 'privacy' %}">{% trans "Privacy Policy" %}</a> <a href ="{% url 'privacy' %}">{% trans "Privacy Policy" %}</a>

View file

@ -64,12 +64,14 @@
<div> <div>
<div class="columns is-mobile"> <div class="columns is-mobile">
<h2 class="title column">{% trans "User Activity" %}</h2> <h2 class="title column">{% trans "User Activity" %}</h2>
{% if user.local %}
<div class="column is-narrow"> <div class="column is-narrow">
<a target="_blank" href="{{ user.local_path }}/rss" rel="nofollow noopener noreferrer"> <a target="_blank" href="{{ user.local_path }}/rss" rel="nofollow noopener noreferrer">
<span class="icon icon-rss" aria-hidden="true"></span> <span class="icon icon-rss" aria-hidden="true"></span>
<span class="is-hidden-mobile">{% trans "RSS feed" %}</span> <span class="is-hidden-mobile">{% trans "RSS feed" %}</span>
</a> </a>
</div> </div>
{% endif %}
</div> </div>
{% for activity in activities %} {% for activity in activities %}
<div class="block" id="feed_{{ activity.id }}"> <div class="block" id="feed_{{ activity.id }}">

View file

@ -42,7 +42,7 @@ def get_relationship(context, user_object):
"""caches the relationship between the logged in user and another user""" """caches the relationship between the logged in user and another user"""
user = context["request"].user user = context["request"].user
return get_or_set( return get_or_set(
f"relationship-{user.id}-{user_object.id}", f"cached-relationship-{user.id}-{user_object.id}",
get_relationship_name, get_relationship_name,
user, user,
user_object, user_object,

View file

@ -1,10 +1,12 @@
""" testing models """ """ testing models """
import json import json
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import Group
from django.test import TestCase from django.test import TestCase
import responses import responses
from bookwyrm import models from bookwyrm import models
from bookwyrm.management.commands import initdb
from bookwyrm.settings import USE_HTTPS, DOMAIN from bookwyrm.settings import USE_HTTPS, DOMAIN
# pylint: disable=missing-class-docstring # pylint: disable=missing-class-docstring
@ -12,6 +14,7 @@ from bookwyrm.settings import USE_HTTPS, DOMAIN
class User(TestCase): class User(TestCase):
protocol = "https://" if USE_HTTPS else "http://" protocol = "https://" if USE_HTTPS else "http://"
# pylint: disable=invalid-name
def setUp(self): def setUp(self):
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay" "bookwyrm.activitystreams.populate_stream_task.delay"
@ -25,6 +28,17 @@ class User(TestCase):
name="hi", name="hi",
bookwyrm_user=False, bookwyrm_user=False,
) )
self.another_user = models.User.objects.create_user(
f"nutria@{DOMAIN}",
"nutria@nutria.nutria",
"nutriaword",
local=True,
localname="nutria",
name="hi",
bookwyrm_user=False,
)
initdb.init_groups()
initdb.init_permissions()
def test_computed_fields(self): def test_computed_fields(self):
"""username instead of id here""" """username instead of id here"""
@ -176,3 +190,41 @@ class User(TestCase):
self.assertEqual(activity["type"], "Delete") self.assertEqual(activity["type"], "Delete")
self.assertEqual(activity["object"], self.user.remote_id) self.assertEqual(activity["object"], self.user.remote_id)
self.assertFalse(self.user.is_active) self.assertFalse(self.user.is_active)
def test_admins_no_admins(self):
"""list of admins"""
result = models.User.admins()
self.assertFalse(result.exists())
def test_admins_superuser(self):
"""list of admins"""
self.user.is_superuser = True
self.user.save(broadcast=False, update_fields=["is_superuser"])
result = models.User.admins()
self.assertEqual(result.count(), 1)
self.assertEqual(result.first(), self.user)
def test_admins_superuser_and_mod(self):
"""list of admins"""
self.user.is_superuser = True
self.user.save(broadcast=False, update_fields=["is_superuser"])
group = Group.objects.get(name="moderator")
self.another_user.groups.set([group])
results = models.User.admins()
self.assertEqual(results.count(), 2)
self.assertTrue(results.filter(id=self.user.id).exists())
self.assertTrue(results.filter(id=self.another_user.id).exists())
def test_admins_deleted_mod(self):
"""list of admins"""
self.user.is_superuser = True
self.user.save(broadcast=False, update_fields=["is_superuser"])
group = Group.objects.get(name="moderator")
self.another_user.groups.set([group])
self.another_user.is_active = False
self.another_user.save(broadcast=False, update_fields=None)
results = models.User.admins()
self.assertEqual(results.count(), 1)
self.assertEqual(results.first(), self.user)

View file

@ -301,6 +301,16 @@ urlpatterns = [
views.ImportList.as_view(), views.ImportList.as_view(),
name="settings-imports-complete", name="settings-imports-complete",
), ),
re_path(
r"^settings/imports/disable/?$",
views.disable_imports,
name="settings-imports-disable",
),
re_path(
r"^settings/imports/enable/?$",
views.enable_imports,
name="settings-imports-enable",
),
re_path( re_path(
r"^settings/celery/?$", views.CeleryStatus.as_view(), name="settings-celery" r"^settings/celery/?$", views.CeleryStatus.as_view(), name="settings-celery"
), ),

View file

@ -10,7 +10,7 @@ from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist from .admin.federation import AddFederatedServer, ImportServerBlocklist
from .admin.federation import block_server, unblock_server, refresh_server from .admin.federation import block_server, unblock_server, refresh_server
from .admin.email_blocklist import EmailBlocklist from .admin.email_blocklist import EmailBlocklist
from .admin.imports import ImportList from .admin.imports import ImportList, disable_imports, enable_imports
from .admin.ip_blocklist import IPBlocklist from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest from .admin.invite import ManageInvites, Invite, InviteRequest
from .admin.invite import ManageInviteRequests, ignore_invite_request from .admin.invite import ManageInviteRequests, ignore_invite_request

View file

@ -5,6 +5,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import models from bookwyrm import models
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
@ -53,3 +54,25 @@ class ImportList(View):
import_job = get_object_or_404(models.ImportJob, id=import_id) import_job = get_object_or_404(models.ImportJob, id=import_id)
import_job.stop_job() import_job.stop_job()
return redirect("settings-imports") return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def disable_imports(request):
"""When you just need people to please stop starting imports"""
site = models.SiteSettings.objects.get()
site.imports_enabled = False
site.save(update_fields=["imports_enabled"])
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def enable_imports(request):
"""When you just need people to please stop starting imports"""
site = models.SiteSettings.objects.get()
site.imports_enabled = True
site.save(update_fields=["imports_enabled"])
return redirect("settings-imports")

View file

@ -85,6 +85,11 @@ class Invite(View):
# post handling is in views.register.Register # post handling is in views.register.Register
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.create_invites", raise_exception=True),
name="dispatch",
)
class ManageInviteRequests(View): class ManageInviteRequests(View):
"""grant invites like the benevolent lord you are""" """grant invites like the benevolent lord you are"""
@ -177,6 +182,7 @@ class InviteRequest(View):
@require_POST @require_POST
@permission_required("bookwyrm.create_invites", raise_exception=True)
def ignore_invite_request(request): def ignore_invite_request(request):
"""hide an invite request""" """hide an invite request"""
invite_request = get_object_or_404( invite_request = get_object_or_404(

View file

@ -4,6 +4,7 @@ import datetime
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Avg, ExpressionWrapper, F, fields from django.db.models import Avg, ExpressionWrapper, F, fields
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.shortcuts import redirect from django.shortcuts import redirect
@ -54,6 +55,10 @@ class Import(View):
def post(self, request): def post(self, request):
"""ingest a goodreads csv""" """ingest a goodreads csv"""
site = models.SiteSettings.objects.get()
if not site.imports_enabled:
raise PermissionDenied()
form = forms.ImportForm(request.POST, request.FILES) form = forms.ImportForm(request.POST, request.FILES)
if not form.is_valid(): if not form.is_valid():
return HttpResponseBadRequest() return HttpResponseBadRequest()