summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-12-05 19:21:00 -0700
committerAndrew Godwin2022-12-05 19:24:46 -0700
commita31f676b46a4d904954b8b7227dcde779aedca54 (patch)
treed00fa3e022ff08f154f431777ba37e2f43127fd6
parentda9a3d853eda1173ac8913908d512fb9babbd136 (diff)
downloadtakahe-a31f676b46a4d904954b8b7227dcde779aedca54.tar.gz
takahe-a31f676b46a4d904954b8b7227dcde779aedca54.tar.bz2
takahe-a31f676b46a4d904954b8b7227dcde779aedca54.zip
Policy pages and signup tests.
Fixes #113
-rw-r--r--core/middleware.py16
-rw-r--r--core/models/config.py4
-rw-r--r--core/views.py29
-rw-r--r--requirements.txt1
-rw-r--r--static/css/style.css1
-rw-r--r--stator/runner.py14
-rw-r--r--takahe/urls.py26
-rw-r--r--templates/base.html6
-rw-r--r--templates/flatpage.html8
-rw-r--r--templates/forms/_field.html2
-rw-r--r--templates/index.html2
-rw-r--r--templates/settings/_menu.html3
-rw-r--r--tests/activities/models/test_post.py24
-rw-r--r--tests/conftest.py5
-rw-r--r--tests/users/views/test_auth.py125
-rw-r--r--users/views/admin/__init__.py6
-rw-r--r--users/views/admin/settings.py33
-rw-r--r--users/views/auth.py37
18 files changed, 275 insertions, 67 deletions
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
@@ -61,6 +61,11 @@ urlpatterns = [
name="admin_tuning",
),
path(
+ "admin/policies/",
+ admin.PoliciesSettings.as_view(),
+ name="admin_policies",
+ ),
+ path(
"admin/domains/",
admin.Domains.as_view(),
name="admin_domains",
@@ -150,6 +155,27 @@ urlpatterns = [
path("@<handle>/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 @@
</main>
<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>
</body>
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 %}
+ <h1>{{ title }}</h1>
+ {{ 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 @@
</label>
{% if field.help_text %}
<p class="help">
- {{ field.help_text|linebreaksbr }}
+ {{ field.help_text|safe|linebreaksbr }}
</p>
{% 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 %}
<div class="about">
<img class="banner" src="{{ config.site_banner }}">
- {{ config.site_about|safe|linebreaks }}
+ {{ about }}
</div>
<h2>People</h2>
{% 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 @@
<a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %} title="Basic">
<i class="fa-solid fa-book"></i> Basic
</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">
<i class="fa-solid fa-globe"></i> Domains
</a>
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() == "<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
-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 "<button>Create</button>" not in content
+ response = client.get("/auth/signup/")
+ assertContains(response, "Not accepting new users at this time", status_code=200)
+ assertNotContains(response, "<button>Create</button>")
# 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 "<button>Create</button>" in content
+ response = client.get("/auth/signup/")
+ assertContains(response, "<button>Create</button>", 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)
+
+ # 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()
- # TODO: Actually test this
+ # 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("<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):
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):