diff --git a/core/middleware.py b/core/middleware.py index bd89d1c..274f672 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -11,19 +11,21 @@ class ConfigLoadingMiddleware: Caches the system config every request """ - refresh_interval: float = 30.0 + refresh_interval: float = 5.0 def __init__(self, get_response): self.get_response = get_response self.config_ts: float = 0.0 def __call__(self, request): - if ( - not getattr(Config, "system", None) - or (time() - self.config_ts) >= self.refresh_interval - ): - Config.system = Config.load_system() - self.config_ts = time() + # Allow test fixtures to force and lock the config + if not getattr(Config, "__forced__", False): + if ( + not getattr(Config, "system", None) + or (time() - self.config_ts) >= self.refresh_interval + ): + Config.system = Config.load_system() + self.config_ts = time() response = self.get_response(request) return response diff --git a/core/models/config.py b/core/models/config.py index b18471e..53c729f 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -204,6 +204,10 @@ class Config(models.Model): site_icon: UploadedImage = static("img/icon-128.png") site_banner: UploadedImage = static("img/fjords-banner-600.jpg") + policy_terms: str = "" + policy_privacy: str = "" + policy_rules: str = "" + signup_allowed: bool = True signup_invite_only: bool = False signup_text: str = "" diff --git a/core/views.py b/core/views.py index ea8a1ca..a09d925 100644 --- a/core/views.py +++ b/core/views.py @@ -1,10 +1,13 @@ +import markdown_it from django.http import JsonResponse from django.templatetags.static import static from django.utils.decorators import method_decorator +from django.utils.safestring import mark_safe from django.views.generic import TemplateView, View from activities.views.timelines import Home from core.decorators import cache_page +from core.models import Config from users.models import Identity @@ -22,6 +25,9 @@ class LoggedOutHomepage(TemplateView): def get_context_data(self): return { + "about": mark_safe( + markdown_it.MarkdownIt().render(Config.system.site_about) + ), "identities": Identity.objects.filter( local=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), + } diff --git a/requirements.txt b/requirements.txt index 2560fc5..4cdbb82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ django~=4.1 email-validator~=1.3.0 gunicorn~=20.1.0 httpx~=0.23 +markdown_it_py~=2.1.0 pillow~=9.3.0 psycopg2~=2.9.5 pydantic~=1.10.2 diff --git a/static/css/style.css b/static/css/style.css index b836f4f..34b00a2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -127,6 +127,7 @@ footer { footer a { border-bottom: 1px solid var(--color-text-duller); + margin-right: 5px; } header { diff --git a/stator/runner.py b/stator/runner.py index 0d8f9ea..7305a6e 100644 --- a/stator/runner.py +++ b/stator/runner.py @@ -4,6 +4,7 @@ import time import traceback import uuid +from asgiref.sync import async_to_sync from django.utils import timezone from core import exceptions, sentry @@ -142,3 +143,16 @@ class StatorRunner: Removes all completed asyncio.Tasks from our local in-progress list """ 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) diff --git a/takahe/urls.py b/takahe/urls.py index a6e8a6e..66f176d 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -60,6 +60,11 @@ urlpatterns = [ admin.TuningSettings.as_view(), name="admin_tuning", ), + path( + "admin/policies/", + admin.PoliciesSettings.as_view(), + name="admin_policies", + ), path( "admin/domains/", admin.Domains.as_view(), @@ -150,6 +155,27 @@ urlpatterns = [ path("@/activate/", identity.ActivateIdentity.as_view()), path("identity/select/", identity.SelectIdentity.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 path(".well-known/webfinger", activitypub.Webfinger.as_view()), path(".well-known/host-meta", activitypub.HostMeta.as_view()), diff --git a/templates/base.html b/templates/base.html index 2fd92bf..b30e38f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -75,7 +75,11 @@ diff --git a/templates/flatpage.html b/templates/flatpage.html new file mode 100644 index 0000000..b24a6b8 --- /dev/null +++ b/templates/flatpage.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title%}{{ title }}{% endblock %} + +{% block content %} +

{{ title }}

+ {{ content }} +{% endblock %} diff --git a/templates/forms/_field.html b/templates/forms/_field.html index d101889..b4df509 100644 --- a/templates/forms/_field.html +++ b/templates/forms/_field.html @@ -6,7 +6,7 @@ {% if field.help_text %}

- {{ field.help_text|linebreaksbr }} + {{ field.help_text|safe|linebreaksbr }}

{% endif %} {{ field.errors }} diff --git a/templates/index.html b/templates/index.html index 79f81cf..72dcf84 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,7 +6,7 @@ {% block content %}
- {{ config.site_about|safe|linebreaks }} + {{ about }}

People

{% for identity in identities %} diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index fa2e74e..bcb404d 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -18,6 +18,9 @@ Basic + + Policies + Domains diff --git a/tests/activities/models/test_post.py b/tests/activities/models/test_post.py index 21e7000..00db61b 100644 --- a/tests/activities/models/test_post.py +++ b/tests/activities/models/test_post.py @@ -1,7 +1,4 @@ -import asyncio - import pytest -from asgiref.sync import async_to_sync from pytest_httpx import HTTPXMock from activities.models import Post, PostStates @@ -128,21 +125,8 @@ def test_linkify_mentions_local(identity, remote_identity): assert post.safe_content_local() == "

@test@example.com, welcome!

" -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 -def test_post_transitions(identity, stator_runner): +def test_post_transitions(identity, stator): # Create post post = Post.objects.create( @@ -153,18 +137,18 @@ def test_post_transitions(identity, stator_runner): ) # Test: | --> new --> fanned_out 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) assert post.state == str(PostStates.fanned_out) # Test: fanned_out --> (forced) edited --> edited_fanned_out 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) assert post.state == str(PostStates.edited_fanned_out) # Test: edited_fanned_out --> (forced) deleted --> deleted_fanned_out 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) assert post.state == str(PostStates.deleted_fanned_out) diff --git a/tests/conftest.py b/tests/conftest.py index a3feaca..80622f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,7 +60,10 @@ def config_system(keypair): system_actor_private_key=keypair["private_key"], system_actor_public_key=keypair["public_key"], ) + Config.__forced__ = True yield Config.system + Config.__forced__ = False + del Config.system @pytest.fixture @@ -126,7 +129,7 @@ def remote_identity() -> Identity: @pytest.fixture -def stator_runner(config_system) -> StatorRunner: +def stator(config_system) -> StatorRunner: """ Return an initialized StatorRunner for tests that need state transitioning to happen. diff --git a/tests/users/views/test_auth.py b/tests/users/views/test_auth.py index f3a34c0..6dd1010 100644 --- a/tests/users/views/test_auth.py +++ b/tests/users/views/test_auth.py @@ -1,60 +1,119 @@ -from unittest import mock - import pytest +from django.core import mail +from pytest_django.asserts import assertContains, assertNotContains -from core.models import Config -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 +from users.models import Invite, User @pytest.mark.django_db def test_signup_disabled(client, config_system): + """ + Tests that disabling signup takes effect + """ # Signup disabled and no signup text config_system.signup_allowed = False - resp = client.get("/auth/signup/") - assert resp.status_code == 200 - content = str(resp.content) - assert "Not accepting new users at this time" in content - assert "" not in content + response = client.get("/auth/signup/") + assertContains(response, "Not accepting new users at this time", status_code=200) + assertNotContains(response, "") # Signup disabled with signup text configured config_system.signup_text = "Go away!!!!!!" - resp = client.get("/auth/signup/") - assert resp.status_code == 200 - content = str(resp.content) - assert "Go away!!!!!!" in content + response = client.get("/auth/signup/") + assertContains(response, "Go away!!!!!!", status_code=200) # Ensure direct POST doesn't side step guard - resp = client.post( + response = client.post( "/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() # Signup enabled config_system.signup_allowed = True - resp = client.get("/auth/signup/") - assert resp.status_code == 200 - content = str(resp.content) - assert "Not accepting new users at this time" not in content - assert "" in content + response = client.get("/auth/signup/") + assertContains(response, "", status_code=200) + assertNotContains(response, "Not accepting new users at this time") @pytest.mark.django_db def test_signup_invite_only(client, config_system): + """ + Tests that invite codes work with signup + """ config_system.signup_allowed = True config_system.signup_invite_only = True - resp = client.get("/auth/signup/") - assert resp.status_code == 200 - content = str(resp.content) - assert 'name="invite_code"' in content + # Try to sign up without an invite code + response = client.post("/auth/signup/", {"email": "random@example.com"}) + assertNotContains(response, "Email Sent", status_code=200) - # 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 diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py index 04e1195..b8ebc40 100644 --- a/users/views/admin/__init__.py +++ b/users/views/admin/__init__.py @@ -17,7 +17,11 @@ from users.views.admin.hashtags import ( # noqa HashtagEdit, 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") diff --git a/users/views/admin/settings.py b/users/views/admin/settings.py index dc56693..a4e0190 100644 --- a/users/views/admin/settings.py +++ b/users/views/admin/settings.py @@ -44,7 +44,7 @@ class BasicSettings(AdminSettingsPage): }, "site_about": { "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", }, "site_icon": { @@ -155,3 +155,34 @@ class TuningSettings(AdminSettingsPage): "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", + ], + } diff --git a/users/views/auth.py b/users/views/auth.py index 61e9a29..acb22b6 100644 --- a/users/views/auth.py +++ b/users/views/auth.py @@ -30,10 +30,40 @@ class Signup(FormView): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Add the invite field if it's enabled if Config.system.signup_invite_only: self.fields["invite_code"] = forms.CharField( 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("Server Rules") + if Config.system.policy_terms: + policies.append("Terms of Service") + if Config.system.policy_privacy: + policies.append("Privacy Policy") + 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): email = self.cleaned_data.get("email").lower() @@ -45,8 +75,13 @@ class Signup(FormView): def clean_invite_code(self): 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") + 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 def clean(self):