mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 08:41:00 +00:00
parent
da9a3d853e
commit
a31f676b46
18 changed files with 275 additions and 67 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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 = ""
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -127,6 +127,7 @@ footer {
|
|||
|
||||
footer a {
|
||||
border-bottom: 1px solid var(--color-text-duller);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
header {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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("@<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()),
|
||||
|
|
|
@ -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>
|
||||
|
|
8
templates/flatpage.html
Normal file
8
templates/flatpage.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title%}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ title }}</h1>
|
||||
{{ content }}
|
||||
{% endblock %}
|
|
@ -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 }}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
# 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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in a new issue