Policy pages and signup tests.

Fixes #113
This commit is contained in:
Andrew Godwin 2022-12-05 19:21:00 -07:00
parent da9a3d853e
commit a31f676b46
18 changed files with 275 additions and 67 deletions

View file

@ -11,19 +11,21 @@ class ConfigLoadingMiddleware:
Caches the system config every request Caches the system config every request
""" """
refresh_interval: float = 30.0 refresh_interval: float = 5.0
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
self.config_ts: float = 0.0 self.config_ts: float = 0.0
def __call__(self, request): def __call__(self, request):
if ( # Allow test fixtures to force and lock the config
not getattr(Config, "system", None) if not getattr(Config, "__forced__", False):
or (time() - self.config_ts) >= self.refresh_interval if (
): not getattr(Config, "system", None)
Config.system = Config.load_system() or (time() - self.config_ts) >= self.refresh_interval
self.config_ts = time() ):
Config.system = Config.load_system()
self.config_ts = time()
response = self.get_response(request) response = self.get_response(request)
return response return response

View file

@ -204,6 +204,10 @@ class Config(models.Model):
site_icon: UploadedImage = static("img/icon-128.png") site_icon: UploadedImage = static("img/icon-128.png")
site_banner: UploadedImage = static("img/fjords-banner-600.jpg") site_banner: UploadedImage = static("img/fjords-banner-600.jpg")
policy_terms: str = ""
policy_privacy: str = ""
policy_rules: str = ""
signup_allowed: bool = True signup_allowed: bool = True
signup_invite_only: bool = False signup_invite_only: bool = False
signup_text: str = "" signup_text: str = ""

View file

@ -1,10 +1,13 @@
import markdown_it
from django.http import JsonResponse from django.http import JsonResponse
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.safestring import mark_safe
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from activities.views.timelines import Home from activities.views.timelines import Home
from core.decorators import cache_page from core.decorators import cache_page
from core.models import Config
from users.models import Identity from users.models import Identity
@ -22,6 +25,9 @@ class LoggedOutHomepage(TemplateView):
def get_context_data(self): def get_context_data(self):
return { return {
"about": mark_safe(
markdown_it.MarkdownIt().render(Config.system.site_about)
),
"identities": Identity.objects.filter( "identities": Identity.objects.filter(
local=True, local=True,
discoverable=True, discoverable=True,
@ -60,3 +66,26 @@ class AppManifest(View):
], ],
} }
) )
class FlatPage(TemplateView):
"""
Serves a "flat page" from a config option,
returning 404 if it is empty.
"""
template_name = "flatpage.html"
config_option = None
title = None
def get_context_data(self):
if self.config_option is None:
raise ValueError("No config option provided")
# Get raw content
content = getattr(Config.system, self.config_option)
# Render it
html = markdown_it.MarkdownIt().render(content)
return {
"title": self.title,
"content": mark_safe(html),
}

View file

@ -9,6 +9,7 @@ django~=4.1
email-validator~=1.3.0 email-validator~=1.3.0
gunicorn~=20.1.0 gunicorn~=20.1.0
httpx~=0.23 httpx~=0.23
markdown_it_py~=2.1.0
pillow~=9.3.0 pillow~=9.3.0
psycopg2~=2.9.5 psycopg2~=2.9.5
pydantic~=1.10.2 pydantic~=1.10.2

View file

@ -127,6 +127,7 @@ footer {
footer a { footer a {
border-bottom: 1px solid var(--color-text-duller); border-bottom: 1px solid var(--color-text-duller);
margin-right: 5px;
} }
header { header {

View file

@ -4,6 +4,7 @@ import time
import traceback import traceback
import uuid import uuid
from asgiref.sync import async_to_sync
from django.utils import timezone from django.utils import timezone
from core import exceptions, sentry from core import exceptions, sentry
@ -142,3 +143,16 @@ class StatorRunner:
Removes all completed asyncio.Tasks from our local in-progress list Removes all completed asyncio.Tasks from our local in-progress list
""" """
self.tasks = [t for t in self.tasks if not t.done()] self.tasks = [t for t in self.tasks if not t.done()]
async def run_single_cycle(self):
"""
Testing entrypoint to advance things just one cycle
"""
await asyncio.wait_for(self.fetch_and_process_tasks(), timeout=1)
for _ in range(100):
if not self.tasks:
break
self.remove_completed_tasks()
await asyncio.sleep(0.01)
run_single_cycle_sync = async_to_sync(run_single_cycle)

View file

@ -60,6 +60,11 @@ urlpatterns = [
admin.TuningSettings.as_view(), admin.TuningSettings.as_view(),
name="admin_tuning", name="admin_tuning",
), ),
path(
"admin/policies/",
admin.PoliciesSettings.as_view(),
name="admin_policies",
),
path( path(
"admin/domains/", "admin/domains/",
admin.Domains.as_view(), admin.Domains.as_view(),
@ -150,6 +155,27 @@ urlpatterns = [
path("@<handle>/activate/", identity.ActivateIdentity.as_view()), path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()), path("identity/select/", identity.SelectIdentity.as_view()),
path("identity/create/", identity.CreateIdentity.as_view()), path("identity/create/", identity.CreateIdentity.as_view()),
# Flat pages
path(
"about/",
core.FlatPage.as_view(title="About This Server", config_option="site_about"),
name="about",
),
path(
"pages/privacy/",
core.FlatPage.as_view(title="Privacy Policy", config_option="policy_privacy"),
name="privacy",
),
path(
"pages/terms/",
core.FlatPage.as_view(title="Terms of Service", config_option="policy_terms"),
name="terms",
),
path(
"pages/rules/",
core.FlatPage.as_view(title="Server Rules", config_option="policy_rules"),
name="rules",
),
# Well-known endpoints and system actor # Well-known endpoints and system actor
path(".well-known/webfinger", activitypub.Webfinger.as_view()), path(".well-known/webfinger", activitypub.Webfinger.as_view()),
path(".well-known/host-meta", activitypub.HostMeta.as_view()), path(".well-known/host-meta", activitypub.HostMeta.as_view()),

View file

@ -75,7 +75,11 @@
</main> </main>
<footer> <footer>
<span>Powered by <a href="https://jointakahe.org">Takahē {{ config.version }}</a></span> {% if config.site_about %}<a href="{% url "about" %}">About</a>{% endif %}
{% if config.policy_rules %}<a href="{% url "rules" %}">Server Rules</a>{% endif %}
{% if config.policy_terms %}<a href="{% url "terms" %}">Terms of Service</a>{% endif %}
{% if config.policy_privacy %}<a href="{% url "privacy" %}">Privacy Policy</a>{% endif %}
<a href="https://jointakahe.org">Takahē {{ config.version }}</a>
</footer> </footer>
</body> </body>

8
templates/flatpage.html Normal file
View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title%}{{ title }}{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
{{ content }}
{% endblock %}

View file

@ -6,7 +6,7 @@
</label> </label>
{% if field.help_text %} {% if field.help_text %}
<p class="help"> <p class="help">
{{ field.help_text|linebreaksbr }} {{ field.help_text|safe|linebreaksbr }}
</p> </p>
{% endif %} {% endif %}
{{ field.errors }} {{ field.errors }}

View file

@ -6,7 +6,7 @@
{% block content %} {% block content %}
<div class="about"> <div class="about">
<img class="banner" src="{{ config.site_banner }}"> <img class="banner" src="{{ config.site_banner }}">
{{ config.site_about|safe|linebreaks }} {{ about }}
</div> </div>
<h2>People</h2> <h2>People</h2>
{% for identity in identities %} {% for identity in identities %}

View file

@ -18,6 +18,9 @@
<a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %} title="Basic"> <a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %} title="Basic">
<i class="fa-solid fa-book"></i> Basic <i class="fa-solid fa-book"></i> Basic
</a> </a>
<a href="{% url "admin_policies" %}" {% if section == "policies" %}class="selected"{% endif %} title="Policies">
<i class="fa-solid fa-file-lines"></i> Policies
</a>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains"> <a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains">
<i class="fa-solid fa-globe"></i> Domains <i class="fa-solid fa-globe"></i> Domains
</a> </a>

View file

@ -1,7 +1,4 @@
import asyncio
import pytest import pytest
from asgiref.sync import async_to_sync
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from activities.models import Post, PostStates from activities.models import Post, PostStates
@ -128,21 +125,8 @@ def test_linkify_mentions_local(identity, remote_identity):
assert post.safe_content_local() == "<p>@test@example.com, welcome!</p>" assert post.safe_content_local() == "<p>@test@example.com, welcome!</p>"
async def stator_process_tasks(stator):
"""
Guarded wrapper to simply async_to_sync and ensure all stator tasks are
run to completion without blocking indefinitely.
"""
await asyncio.wait_for(stator.fetch_and_process_tasks(), timeout=1)
for _ in range(100):
if not stator.tasks:
break
stator.remove_completed_tasks()
await asyncio.sleep(0.01)
@pytest.mark.django_db @pytest.mark.django_db
def test_post_transitions(identity, stator_runner): def test_post_transitions(identity, stator):
# Create post # Create post
post = Post.objects.create( post = Post.objects.create(
@ -153,18 +137,18 @@ def test_post_transitions(identity, stator_runner):
) )
# Test: | --> new --> fanned_out # Test: | --> new --> fanned_out
assert post.state == str(PostStates.new) assert post.state == str(PostStates.new)
async_to_sync(stator_process_tasks)(stator_runner) stator.run_single_cycle_sync()
post = Post.objects.get(id=post.id) post = Post.objects.get(id=post.id)
assert post.state == str(PostStates.fanned_out) assert post.state == str(PostStates.fanned_out)
# Test: fanned_out --> (forced) edited --> edited_fanned_out # Test: fanned_out --> (forced) edited --> edited_fanned_out
Post.transition_perform(post, PostStates.edited) Post.transition_perform(post, PostStates.edited)
async_to_sync(stator_process_tasks)(stator_runner) stator.run_single_cycle_sync()
post = Post.objects.get(id=post.id) post = Post.objects.get(id=post.id)
assert post.state == str(PostStates.edited_fanned_out) assert post.state == str(PostStates.edited_fanned_out)
# Test: edited_fanned_out --> (forced) deleted --> deleted_fanned_out # Test: edited_fanned_out --> (forced) deleted --> deleted_fanned_out
Post.transition_perform(post, PostStates.deleted) Post.transition_perform(post, PostStates.deleted)
async_to_sync(stator_process_tasks)(stator_runner) stator.run_single_cycle_sync()
post = Post.objects.get(id=post.id) post = Post.objects.get(id=post.id)
assert post.state == str(PostStates.deleted_fanned_out) assert post.state == str(PostStates.deleted_fanned_out)

View file

@ -60,7 +60,10 @@ def config_system(keypair):
system_actor_private_key=keypair["private_key"], system_actor_private_key=keypair["private_key"],
system_actor_public_key=keypair["public_key"], system_actor_public_key=keypair["public_key"],
) )
Config.__forced__ = True
yield Config.system yield Config.system
Config.__forced__ = False
del Config.system
@pytest.fixture @pytest.fixture
@ -126,7 +129,7 @@ def remote_identity() -> Identity:
@pytest.fixture @pytest.fixture
def stator_runner(config_system) -> StatorRunner: def stator(config_system) -> StatorRunner:
""" """
Return an initialized StatorRunner for tests that need state transitioning Return an initialized StatorRunner for tests that need state transitioning
to happen. to happen.

View file

@ -1,60 +1,119 @@
from unittest import mock
import pytest import pytest
from django.core import mail
from pytest_django.asserts import assertContains, assertNotContains
from core.models import Config from users.models import Invite, User
from users.models import User
@pytest.fixture
def config_system():
# TODO: Good enough for now, but a better Config mocking system is needed
result = Config.load_system()
with mock.patch("core.models.Config.load_system", return_value=result):
yield result
@pytest.mark.django_db @pytest.mark.django_db
def test_signup_disabled(client, config_system): def test_signup_disabled(client, config_system):
"""
Tests that disabling signup takes effect
"""
# Signup disabled and no signup text # Signup disabled and no signup text
config_system.signup_allowed = False config_system.signup_allowed = False
resp = client.get("/auth/signup/") response = client.get("/auth/signup/")
assert resp.status_code == 200 assertContains(response, "Not accepting new users at this time", status_code=200)
content = str(resp.content) assertNotContains(response, "<button>Create</button>")
assert "Not accepting new users at this time" in content
assert "<button>Create</button>" not in content
# Signup disabled with signup text configured # Signup disabled with signup text configured
config_system.signup_text = "Go away!!!!!!" config_system.signup_text = "Go away!!!!!!"
resp = client.get("/auth/signup/") response = client.get("/auth/signup/")
assert resp.status_code == 200 assertContains(response, "Go away!!!!!!", status_code=200)
content = str(resp.content)
assert "Go away!!!!!!" in content
# Ensure direct POST doesn't side step guard # Ensure direct POST doesn't side step guard
resp = client.post( response = client.post(
"/auth/signup/", data={"email": "test_signup_disabled@example.org"} "/auth/signup/", data={"email": "test_signup_disabled@example.org"}
) )
assert resp.status_code == 200 assert response.status_code == 200
assert not User.objects.filter(email="test_signup_disabled@example.org").exists() assert not User.objects.filter(email="test_signup_disabled@example.org").exists()
# Signup enabled # Signup enabled
config_system.signup_allowed = True config_system.signup_allowed = True
resp = client.get("/auth/signup/") response = client.get("/auth/signup/")
assert resp.status_code == 200 assertContains(response, "<button>Create</button>", status_code=200)
content = str(resp.content) assertNotContains(response, "Not accepting new users at this time")
assert "Not accepting new users at this time" not in content
assert "<button>Create</button>" in content
@pytest.mark.django_db @pytest.mark.django_db
def test_signup_invite_only(client, config_system): def test_signup_invite_only(client, config_system):
"""
Tests that invite codes work with signup
"""
config_system.signup_allowed = True config_system.signup_allowed = True
config_system.signup_invite_only = True config_system.signup_invite_only = True
resp = client.get("/auth/signup/") # Try to sign up without an invite code
assert resp.status_code == 200 response = client.post("/auth/signup/", {"email": "random@example.com"})
content = str(resp.content) assertNotContains(response, "Email Sent", status_code=200)
assert 'name="invite_code"' in content
# TODO: Actually test this # Make an invite code for any email
invite_any = Invite.create_random()
response = client.post(
"/auth/signup/",
{"email": "random@example.com", "invite_code": invite_any.token},
)
assertNotContains(response, "not a valid invite")
assertContains(response, "Email Sent", status_code=200)
# Make sure you can't reuse an invite code
response = client.post(
"/auth/signup/",
{"email": "random2@example.com", "invite_code": invite_any.token},
)
assertNotContains(response, "Email Sent", status_code=200)
# Make an invite code for a specific email
invite_specific = Invite.create_random(email="special@example.com")
response = client.post(
"/auth/signup/",
{"email": "random3@example.com", "invite_code": invite_specific.token},
)
assertContains(response, "valid invite code for this email", status_code=200)
assertNotContains(response, "Email Sent")
response = client.post(
"/auth/signup/",
{"email": "special@example.com", "invite_code": invite_specific.token},
)
assertContains(response, "Email Sent", status_code=200)
@pytest.mark.django_db
def test_signup_policy(client, config_system):
"""
Tests that you must agree to policies to sign up
"""
config_system.signup_allowed = True
config_system.signup_invite_only = False
# Make sure we can sign up when there are no policies
response = client.post("/auth/signup/", {"email": "random@example.com"})
assertContains(response, "Email Sent", status_code=200)
# Make sure that's then denied when we have a policy in place
config_system.policy_rules = "You must love unit tests"
response = client.post("/auth/signup/", {"email": "random2@example.com"})
assertContains(response, "field is required", status_code=200)
assertNotContains(response, "Email Sent")
@pytest.mark.django_db
def test_signup_email(client, config_system, stator):
"""
Tests that you can sign up and get an email sent to you
"""
config_system.signup_allowed = True
config_system.signup_invite_only = False
# Sign up with a user
response = client.post("/auth/signup/", {"email": "random@example.com"})
assertContains(response, "Email Sent", status_code=200)
# Verify that made a user object and a password reset
user = User.objects.get(email="random@example.com")
assert user.password_resets.exists()
# Run Stator and verify it sends the email
assert len(mail.outbox) == 0
stator.run_single_cycle_sync()
assert len(mail.outbox) == 1

View file

@ -17,7 +17,11 @@ from users.views.admin.hashtags import ( # noqa
HashtagEdit, HashtagEdit,
Hashtags, Hashtags,
) )
from users.views.admin.settings import BasicSettings, TuningSettings # noqa from users.views.admin.settings import ( # noqa
BasicSettings,
PoliciesSettings,
TuningSettings,
)
@method_decorator(admin_required, name="dispatch") @method_decorator(admin_required, name="dispatch")

View file

@ -44,7 +44,7 @@ class BasicSettings(AdminSettingsPage):
}, },
"site_about": { "site_about": {
"title": "About This Site", "title": "About This Site",
"help_text": "Displayed on the homepage and the about page.\nNewlines are preserved; HTML also allowed.", "help_text": "Displayed on the homepage and the about page.\nUse Markdown for formatting.",
"display": "textarea", "display": "textarea",
}, },
"site_icon": { "site_icon": {
@ -155,3 +155,34 @@ class TuningSettings(AdminSettingsPage):
"cache_timeout_identity_feed", "cache_timeout_identity_feed",
], ],
} }
class PoliciesSettings(AdminSettingsPage):
section = "policies"
options = {
"policy_terms": {
"title": "Terms of Service Page",
"help_text": "Will only be shown if it has content. Use Markdown for formatting.",
"display": "textarea",
},
"policy_privacy": {
"title": "Privacy Policy Page",
"help_text": "Will only be shown if it has content. Use Markdown for formatting.",
"display": "textarea",
},
"policy_rules": {
"title": "Server Rules Page",
"help_text": "Will only be shown if it has content. Use Markdown for formatting.",
"display": "textarea",
},
}
layout = {
"Policies": [
"policy_rules",
"policy_terms",
"policy_privacy",
],
}

View file

@ -30,10 +30,40 @@ class Signup(FormView):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Add the invite field if it's enabled
if Config.system.signup_invite_only: if Config.system.signup_invite_only:
self.fields["invite_code"] = forms.CharField( self.fields["invite_code"] = forms.CharField(
help_text="Your invite code from one of our admins" help_text="Your invite code from one of our admins"
) )
# Add the policies if they're defined
policies = []
if Config.system.policy_rules:
policies.append("<a href='/pages/rules/'>Server Rules</a>")
if Config.system.policy_terms:
policies.append("<a href='/pages/terms/'>Terms of Service</a>")
if Config.system.policy_privacy:
policies.append("<a href='/pages/privacy/'>Privacy Policy</a>")
if policies:
links = ""
for i, policy in enumerate(policies):
if i == 0:
links += policy
elif i == len(policies) - 1:
if len(policies) > 2:
links += ", and "
else:
links += " and "
links += policy
else:
links += ", "
links += policy
self.fields["policy"] = forms.BooleanField(
label="Policies",
help_text=f"Have you read the {links}, and agree to them?",
widget=forms.Select(
choices=[(False, "I do not agree"), (True, "I agree")]
),
)
def clean_email(self): def clean_email(self):
email = self.cleaned_data.get("email").lower() email = self.cleaned_data.get("email").lower()
@ -45,8 +75,13 @@ class Signup(FormView):
def clean_invite_code(self): def clean_invite_code(self):
invite_code = self.cleaned_data["invite_code"].lower().strip() invite_code = self.cleaned_data["invite_code"].lower().strip()
if not Invite.objects.filter(token=invite_code).exists(): invite = Invite.objects.filter(token=invite_code).first()
if not invite:
raise forms.ValidationError("That is not a valid invite code") raise forms.ValidationError("That is not a valid invite code")
if invite.email and invite.email != self.cleaned_data.get("email"):
raise forms.ValidationError(
"That is not a valid invite code for this email address"
)
return invite_code return invite_code
def clean(self): def clean(self):